Skip to main content

player_plugin_package/
plugin_project.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use player_plugin::{
5    PLUGIN_CATALOG_MIGRATION_VERSION, PLUGIN_CATALOG_SCHEMA_VERSION, PluginArtifactDescriptor,
6    PluginCatalogError, PluginProvision, PluginRequirement,
7};
8pub use player_plugin::{
9    PluginArtifactCapability, PluginArtifactFormat, PluginArtifactTransport,
10    PluginRuntimeDependency as PluginRuntimeDependencySource, PluginRuntimeLinkage,
11};
12use player_plugin::{PluginReference, PluginTransport};
13use serde::Deserialize;
14use thiserror::Error;
15use unicode_casefold::UnicodeCaseFold;
16use unicode_normalization::UnicodeNormalization;
17
18use crate::{
19    PluginCapabilityDescriptor, PluginCompatibilityDescriptor, PluginDescriptor,
20    PluginDescriptorError, PluginIdentityDescriptor, PluginRedistributionDescriptor,
21};
22
23pub(crate) const MAX_ARTIFACTS: usize = 32;
24const MAX_PACKAGE_FILES: usize = 128;
25pub(crate) const MAX_RUNTIME_DEPENDENCIES: usize = 32;
26pub(crate) const MAX_ARCHIVE_PATH_BYTES: usize = 512;
27pub(crate) const MAX_TARGET_BYTES: usize = 128;
28pub(crate) const MAX_ARCHITECTURE_BYTES: usize = 64;
29pub(crate) const MAX_MINIMUM_OS_BYTES: usize = 64;
30pub(crate) const MAX_RUNTIME_VALUE_BYTES: usize = 256;
31
32/// Complete author-owned source manifest read from vesper-plugin.toml.
33///
34/// Descriptor and capability fields remain the single source for runtime
35/// registry metadata. Artifact hashes are generated from the declared source
36/// files and are never accepted from author input.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PluginProjectManifest {
39    descriptor: PluginDescriptor,
40    artifacts: Vec<PluginArtifactSource>,
41    package_files: Vec<PluginPackageFileSource>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
45#[serde(deny_unknown_fields)]
46struct PluginProjectManifestWire {
47    schema_version: u32,
48    plugin: PluginIdentityDescriptor,
49    compatibility: PluginCompatibilityDescriptor,
50    capabilities: Vec<PluginCapabilityDescriptor>,
51    #[serde(default)]
52    requires: Vec<PluginRequirement>,
53    #[serde(default)]
54    provides: Vec<PluginProvision>,
55    #[serde(default)]
56    redistribution: Vec<PluginRedistributionDescriptor>,
57    #[serde(default)]
58    artifacts: Vec<PluginArtifactSource>,
59    #[serde(default)]
60    package_files: Vec<PluginPackageFileSource>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct PluginArtifactSource {
66    pub transport: PluginArtifactTransport,
67    pub target: String,
68    pub format: PluginArtifactFormat,
69    pub source: PathBuf,
70    pub path: String,
71    pub architecture: String,
72    pub capabilities: Vec<PluginArtifactCapability>,
73    #[serde(default)]
74    pub minimum_os: Option<String>,
75    #[serde(default)]
76    pub runtime_dependencies: Vec<PluginRuntimeDependencySource>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
80#[serde(rename_all = "kebab-case")]
81pub enum PluginPackageFileKind {
82    License,
83    Notice,
84    RuntimeMetadata,
85    Redistribution,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct PluginPackageFileSource {
91    pub source: PathBuf,
92    pub path: String,
93    pub kind: PluginPackageFileKind,
94}
95
96#[derive(Debug, Error)]
97pub enum PluginProjectManifestError {
98    #[error("invalid vesper-plugin.toml: {0}")]
99    Toml(#[from] toml::de::Error),
100    #[error(transparent)]
101    Descriptor(#[from] PluginDescriptorError),
102    #[error(transparent)]
103    Catalog(#[from] PluginCatalogError),
104    #[error("invalid plugin project field '{field}': {message}")]
105    InvalidField { field: String, message: String },
106    #[error("duplicate package path '{path}'")]
107    DuplicatePackagePath { path: String },
108    #[error("package file path '{path}' conflicts with '{conflicting_path}'")]
109    ConflictingPackagePath {
110        path: String,
111        conflicting_path: String,
112    },
113    #[error(
114        "ambiguous artifact target for transport '{transport}', target '{target}', and architecture '{architecture}'"
115    )]
116    AmbiguousArtifactTarget {
117        transport: &'static str,
118        target: String,
119        architecture: String,
120    },
121}
122
123impl PluginProjectManifest {
124    pub fn from_toml(source: &str) -> Result<Self, PluginProjectManifestError> {
125        let wire: PluginProjectManifestWire = toml::from_str(source)?;
126        let descriptor = PluginDescriptor {
127            schema_version: wire.schema_version,
128            plugin: wire.plugin,
129            compatibility: wire.compatibility,
130            capabilities: wire.capabilities,
131            requires: wire.requires,
132            provides: wire.provides,
133            redistribution: wire.redistribution,
134        };
135        descriptor.validate()?;
136        let project = Self {
137            descriptor,
138            artifacts: wire.artifacts,
139            package_files: wire.package_files,
140        };
141        project.validate_declared_inputs()?;
142        Ok(project)
143    }
144
145    pub fn descriptor(&self) -> &PluginDescriptor {
146        &self.descriptor
147    }
148
149    /// Projects author-owned artifact declarations into pure catalog
150    /// descriptors.  No artifact file is opened and no runtime is created.
151    pub fn artifact_descriptors(
152        &self,
153    ) -> Result<Vec<PluginArtifactDescriptor>, PluginProjectManifestError> {
154        self.artifacts
155            .iter()
156            .map(|artifact| {
157                let descriptor = PluginArtifactDescriptor {
158                    schema_version: PLUGIN_CATALOG_SCHEMA_VERSION,
159                    plugin_id: self.descriptor.plugin.id.clone(),
160                    version: self.descriptor.plugin.version.clone(),
161                    publisher: self.descriptor.plugin.publisher.clone(),
162                    transport: artifact.transport,
163                    target: artifact.target.clone(),
164                    format: artifact.format,
165                    architecture: artifact.architecture.clone(),
166                    abi_major: self.descriptor.compatibility.abi_major,
167                    abi_minor_min: self.descriptor.compatibility.abi_minor_min,
168                    abi_minor_max: self.descriptor.compatibility.abi_minor_max,
169                    capabilities: artifact.capabilities.clone(),
170                    requires: self.descriptor.requires.clone(),
171                    provides: self.descriptor.provides.clone(),
172                    runtime_dependencies: artifact.runtime_dependencies.clone(),
173                    resource_policy: Default::default(),
174                    migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
175                };
176                descriptor.validate()?;
177                Ok(descriptor)
178            })
179            .collect()
180    }
181
182    pub fn artifacts(&self) -> &[PluginArtifactSource] {
183        &self.artifacts
184    }
185
186    pub fn package_files(&self) -> &[PluginPackageFileSource] {
187        &self.package_files
188    }
189
190    pub fn validate_package_inputs(&self) -> Result<(), PluginProjectManifestError> {
191        self.validate_declared_inputs()?;
192        if self.artifacts.is_empty() {
193            return project_invalid("artifacts", "must contain at least one artifact");
194        }
195        if !self
196            .package_files
197            .iter()
198            .any(|file| file.kind == PluginPackageFileKind::License)
199        {
200            return project_invalid("package_files", "must contain at least one license file");
201        }
202        if !self
203            .package_files
204            .iter()
205            .any(|file| file.kind == PluginPackageFileKind::Notice)
206        {
207            return project_invalid("package_files", "must contain at least one notice file");
208        }
209        Ok(())
210    }
211
212    fn validate_declared_inputs(&self) -> Result<(), PluginProjectManifestError> {
213        if self.artifacts.len() > MAX_ARTIFACTS {
214            return project_invalid(
215                "artifacts",
216                format!("must contain at most {MAX_ARTIFACTS} entries"),
217            );
218        }
219        if self.package_files.len() > MAX_PACKAGE_FILES {
220            return project_invalid(
221                "package_files",
222                format!("must contain at most {MAX_PACKAGE_FILES} entries"),
223            );
224        }
225
226        let mut package_paths =
227            HashSet::with_capacity(self.artifacts.len() + self.package_files.len() + 4);
228        for reserved_path in [
229            crate::plugin_package::PLUGIN_PACKAGE_MANIFEST_PATH,
230            crate::plugin_package::PLUGIN_PACKAGE_CHECKSUMS_PATH,
231            crate::plugin_package::PLUGIN_PACKAGE_SIGNATURE_PATH,
232            crate::plugin_package::INSTALL_MARKER_PATH,
233        ] {
234            package_paths.insert(normalized_package_path(reserved_path));
235        }
236        let mut selectors = HashSet::with_capacity(self.artifacts.len());
237        let descriptor_capabilities = self
238            .descriptor
239            .capabilities
240            .iter()
241            .map(|capability| {
242                (
243                    capability.interface_id.as_str(),
244                    capability.instance_id.as_str(),
245                )
246            })
247            .collect::<HashSet<_>>();
248        let mut covered_capabilities = HashSet::with_capacity(descriptor_capabilities.len());
249        for artifact in &self.artifacts {
250            validate_local_source("artifacts.source", &artifact.source)?;
251            validate_archive_path("artifacts.path", &artifact.path)?;
252            insert_archive_file_path(&mut package_paths, &artifact.path)?;
253            validate_text("artifacts.target", &artifact.target, MAX_TARGET_BYTES)?;
254            validate_text(
255                "artifacts.architecture",
256                &artifact.architecture,
257                MAX_ARCHITECTURE_BYTES,
258            )?;
259            if let Some(minimum_os) = artifact.minimum_os.as_deref() {
260                validate_text("artifacts.minimum_os", minimum_os, MAX_MINIMUM_OS_BYTES)?;
261            }
262            match (artifact.transport, artifact.format) {
263                (PluginArtifactTransport::Wasm, PluginArtifactFormat::WasmComponent)
264                | (PluginArtifactTransport::Native, PluginArtifactFormat::Dylib)
265                | (PluginArtifactTransport::Native, PluginArtifactFormat::Aar)
266                | (PluginArtifactTransport::Native, PluginArtifactFormat::Xcframework) => {}
267                _ => {
268                    return project_invalid(
269                        "artifacts.format",
270                        format!(
271                            "format '{}' is incompatible with transport '{}'",
272                            artifact.format.as_str(),
273                            artifact.transport.as_str()
274                        ),
275                    );
276                }
277            }
278            let selector = (
279                artifact.transport,
280                artifact.target.clone(),
281                artifact.architecture.clone(),
282            );
283            if !selectors.insert(selector) {
284                return Err(PluginProjectManifestError::AmbiguousArtifactTarget {
285                    transport: artifact.transport.as_str(),
286                    target: artifact.target.clone(),
287                    architecture: artifact.architecture.clone(),
288                });
289            }
290            if artifact.capabilities.is_empty()
291                || artifact.capabilities.len() > descriptor_capabilities.len()
292            {
293                return project_invalid(
294                    "artifacts.capabilities",
295                    format!(
296                        "must contain 1 to {} descriptor capability references",
297                        descriptor_capabilities.len()
298                    ),
299                );
300            }
301            let mut artifact_capabilities = HashSet::with_capacity(artifact.capabilities.len());
302            for capability in &artifact.capabilities {
303                let key = (
304                    capability.interface_id.as_str(),
305                    capability.instance_id.as_str(),
306                );
307                if !descriptor_capabilities.contains(&key) {
308                    return project_invalid(
309                        "artifacts.capabilities",
310                        format!(
311                            "capability '{}:{}' is not declared by the plugin descriptor",
312                            capability.interface_id, capability.instance_id
313                        ),
314                    );
315                }
316                if !artifact_capabilities.insert(key) {
317                    return project_invalid(
318                        "artifacts.capabilities",
319                        format!(
320                            "duplicate capability '{}:{}'",
321                            capability.interface_id, capability.instance_id
322                        ),
323                    );
324                }
325                covered_capabilities.insert(key);
326            }
327            if artifact.runtime_dependencies.len() > MAX_RUNTIME_DEPENDENCIES {
328                return project_invalid(
329                    "artifacts.runtime_dependencies",
330                    format!("must contain at most {MAX_RUNTIME_DEPENDENCIES} entries"),
331                );
332            }
333            let mut runtime_ids = HashSet::with_capacity(artifact.runtime_dependencies.len());
334            for dependency in &artifact.runtime_dependencies {
335                validate_identity("artifacts.runtime_dependencies.id", &dependency.id)?;
336                validate_text(
337                    "artifacts.runtime_dependencies.version",
338                    &dependency.version,
339                    MAX_RUNTIME_VALUE_BYTES,
340                )?;
341                validate_text(
342                    "artifacts.runtime_dependencies.compatibility_key",
343                    &dependency.compatibility_key,
344                    MAX_RUNTIME_VALUE_BYTES,
345                )?;
346                if !runtime_ids.insert(dependency.id.as_str()) {
347                    return project_invalid(
348                        "artifacts.runtime_dependencies",
349                        format!("duplicate runtime dependency '{}'", dependency.id),
350                    );
351                }
352            }
353        }
354        if !self.artifacts.is_empty() && covered_capabilities != descriptor_capabilities {
355            return project_invalid(
356                "artifacts.capabilities",
357                "every descriptor capability must be provided by at least one artifact",
358            );
359        }
360        for file in &self.package_files {
361            validate_local_source("package_files.source", &file.source)?;
362            validate_archive_path("package_files.path", &file.path)?;
363            let required_prefix = match file.kind {
364                PluginPackageFileKind::License => "licenses/",
365                PluginPackageFileKind::Notice => "notices/",
366                PluginPackageFileKind::RuntimeMetadata => "runtime/",
367                PluginPackageFileKind::Redistribution => "redistribution/",
368            };
369            if !file.path.starts_with(required_prefix) {
370                return project_invalid(
371                    "package_files.path",
372                    format!(
373                        "kind requires an archive path below '{}'",
374                        required_prefix.trim_end_matches('/')
375                    ),
376                );
377            }
378            insert_archive_file_path(&mut package_paths, &file.path)?;
379        }
380        Ok(())
381    }
382}
383
384pub(crate) fn validate_archive_path(
385    field: &str,
386    value: &str,
387) -> Result<(), PluginProjectManifestError> {
388    if value.is_empty()
389        || value.len() > MAX_ARCHIVE_PATH_BYTES
390        || value.starts_with('/')
391        || value.ends_with('/')
392        || value.contains('\\')
393        || value.contains(':')
394        || value.chars().any(char::is_control)
395        || value
396            .split('/')
397            .any(|component| component.is_empty() || matches!(component, "." | ".."))
398    {
399        return project_invalid(
400            field,
401            "must be a bounded relative archive file path without dot segments or backslashes",
402        );
403    }
404    Ok(())
405}
406
407pub(crate) fn normalized_package_path(value: &str) -> String {
408    value.nfc().case_fold().nfc().collect()
409}
410
411pub(crate) fn insert_archive_file_path(
412    paths: &mut HashSet<String>,
413    value: &str,
414) -> Result<(), PluginProjectManifestError> {
415    let normalized = normalized_package_path(value);
416    if paths.contains(&normalized) {
417        return Err(PluginProjectManifestError::DuplicatePackagePath {
418            path: value.to_owned(),
419        });
420    }
421    if let Some(conflicting_path) = paths.iter().find(|existing| {
422        is_archive_path_ancestor(existing, &normalized)
423            || is_archive_path_ancestor(&normalized, existing)
424    }) {
425        return Err(PluginProjectManifestError::ConflictingPackagePath {
426            path: value.to_owned(),
427            conflicting_path: conflicting_path.clone(),
428        });
429    }
430    paths.insert(normalized);
431    Ok(())
432}
433
434fn is_archive_path_ancestor(candidate: &str, path: &str) -> bool {
435    path.strip_prefix(candidate)
436        .is_some_and(|suffix| suffix.starts_with('/'))
437}
438
439fn validate_local_source(field: &str, value: &Path) -> Result<(), PluginProjectManifestError> {
440    if value.as_os_str().is_empty() {
441        return project_invalid(field, "must not be empty");
442    }
443    Ok(())
444}
445
446fn validate_identity(field: &str, value: &str) -> Result<(), PluginProjectManifestError> {
447    PluginReference::new(value, None, PluginTransport::Native)
448        .map(|_| ())
449        .map_err(|error| PluginProjectManifestError::InvalidField {
450            field: field.to_owned(),
451            message: error.to_string(),
452        })
453}
454
455fn validate_text(
456    field: &str,
457    value: &str,
458    maximum_bytes: usize,
459) -> Result<(), PluginProjectManifestError> {
460    if value.is_empty() || value.len() > maximum_bytes {
461        return project_invalid(
462            field,
463            format!("must contain 1 to {maximum_bytes} UTF-8 bytes"),
464        );
465    }
466    Ok(())
467}
468
469fn project_invalid<T>(
470    field: &str,
471    message: impl Into<String>,
472) -> Result<T, PluginProjectManifestError> {
473    Err(PluginProjectManifestError::InvalidField {
474        field: field.to_owned(),
475        message: message.into(),
476    })
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    fn project_toml(extra: &str) -> String {
484        format!(
485            r#"
486schema_version = 1
487
488[plugin]
489id = "dev.vesper.fixture"
490name = "Fixture"
491version = "1.2.3"
492description = "Fixture plugin"
493license = "Apache-2.0"
494publisher = "dev.vesper.publisher"
495
496[compatibility]
497host_sdk = ">=0.4.0, <0.5.0"
498abi_major = 1
499abi_minor_min = 0
500abi_minor_max = 0
501
502[[capabilities]]
503interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7"
504instance_id = "dev.vesper.fixture.post-download"
505interface_major = 1
506interface_minor = 0
507stability = "stable"
508
509{extra}
510"#
511        )
512    }
513
514    #[test]
515    fn project_keeps_descriptor_metadata_authoritative_for_packaging() {
516        let project = PluginProjectManifest::from_toml(&project_toml(
517            r#"
518[[artifacts]]
519transport = "native"
520target = "aarch64-apple-darwin"
521format = "dylib"
522source = "target/plugin with spaces.dylib"
523path = "artifacts/aarch64-apple-darwin/plugin with spaces.dylib"
524architecture = "arm64"
525capabilities = [{ interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.fixture.post-download" }]
526
527[[package_files]]
528source = "LICENSE"
529path = "licenses/LICENSE"
530kind = "license"
531
532[[package_files]]
533source = "NOTICE"
534path = "notices/NOTICE"
535kind = "notice"
536"#,
537        ))
538        .expect("valid project");
539
540        project
541            .validate_package_inputs()
542            .expect("complete package inputs");
543        assert_eq!(project.descriptor().plugin.id, "dev.vesper.fixture");
544        assert_eq!(project.artifacts().len(), 1);
545    }
546
547    #[test]
548    fn project_artifacts_project_to_pure_catalog_descriptors() {
549        let project = PluginProjectManifest::from_toml(&project_toml(
550            r#"
551[[requires]]
552service = "dev.vesper.service.time-stretch"
553requirement = ">=1.0.0, <2.0.0"
554
555[[provides]]
556service = "dev.vesper.service.time-stretch"
557version = "1.4.0"
558
559[[artifacts]]
560transport = "native"
561target = "aarch64-apple-darwin"
562format = "dylib"
563source = "plugin.dylib"
564path = "artifacts/plugin.dylib"
565architecture = "arm64"
566capabilities = [{ interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.fixture.post-download" }]
567"#,
568        ))
569        .expect("valid project");
570        let descriptors = project
571            .artifact_descriptors()
572            .expect("artifact descriptors");
573        assert_eq!(descriptors.len(), 1);
574        assert_eq!(descriptors[0].plugin_id, "dev.vesper.fixture");
575        assert_eq!(descriptors[0].format, PluginArtifactFormat::Dylib);
576        assert_eq!(descriptors[0].requires.len(), 1);
577        assert_eq!(
578            descriptors[0].requires[0].service,
579            "dev.vesper.service.time-stretch"
580        );
581        assert_eq!(descriptors[0].provides.len(), 1);
582        assert_eq!(descriptors[0].provides[0].version, "1.4.0");
583    }
584
585    #[test]
586    fn project_rejects_traversal_and_ambiguous_target_selection() {
587        let traversal = project_toml(
588            r#"
589[[artifacts]]
590transport = "native"
591target = "aarch64-apple-darwin"
592format = "dylib"
593source = "plugin.dylib"
594path = "../plugin.dylib"
595architecture = "arm64"
596capabilities = [{ interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.fixture.post-download" }]
597"#,
598        );
599        assert!(matches!(
600            PluginProjectManifest::from_toml(&traversal),
601            Err(PluginProjectManifestError::InvalidField { ref field, .. })
602                if field == "artifacts.path"
603        ));
604
605        let ambiguous = project_toml(
606            r#"
607[[artifacts]]
608transport = "native"
609target = "aarch64-apple-darwin"
610format = "dylib"
611source = "first.dylib"
612path = "artifacts/first.dylib"
613architecture = "arm64"
614capabilities = [{ interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.fixture.post-download" }]
615
616[[artifacts]]
617transport = "native"
618target = "aarch64-apple-darwin"
619format = "xcframework"
620source = "second.zip"
621path = "artifacts/second.zip"
622architecture = "arm64"
623capabilities = [{ interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.fixture.post-download" }]
624"#,
625        );
626        assert!(matches!(
627            PluginProjectManifest::from_toml(&ambiguous),
628            Err(PluginProjectManifestError::AmbiguousArtifactTarget { .. })
629        ));
630    }
631
632    #[test]
633    fn project_rejects_invalid_artifact_capability_ownership_and_coverage() {
634        let empty = project_toml(
635            r#"
636[[artifacts]]
637transport = "native"
638target = "aarch64-apple-darwin"
639format = "dylib"
640source = "plugin.dylib"
641path = "artifacts/plugin.dylib"
642architecture = "arm64"
643capabilities = []
644"#,
645        );
646        assert!(matches!(
647            PluginProjectManifest::from_toml(&empty),
648            Err(PluginProjectManifestError::InvalidField { ref field, ref message })
649                if field == "artifacts.capabilities" && message.contains("must contain 1")
650        ));
651
652        let unowned = project_toml(
653            r#"
654[[artifacts]]
655transport = "native"
656target = "aarch64-apple-darwin"
657format = "dylib"
658source = "plugin.dylib"
659path = "artifacts/plugin.dylib"
660architecture = "arm64"
661capabilities = [{ interface_id = "c7a69475-79b2-5b5e-a477-08844a5da5d1", instance_id = "dev.vesper.fixture.event-hook" }]
662"#,
663        );
664        assert!(matches!(
665            PluginProjectManifest::from_toml(&unowned),
666            Err(PluginProjectManifestError::InvalidField { ref field, ref message })
667                if field == "artifacts.capabilities"
668                    && message.contains("is not declared by the plugin descriptor")
669        ));
670
671        let duplicate = project_toml(
672            r#"
673[[capabilities]]
674interface_id = "c7a69475-79b2-5b5e-a477-08844a5da5d1"
675instance_id = "dev.vesper.fixture.event-hook"
676interface_major = 1
677interface_minor = 0
678stability = "stable"
679
680[[artifacts]]
681transport = "native"
682target = "aarch64-apple-darwin"
683format = "dylib"
684source = "plugin.dylib"
685path = "artifacts/plugin.dylib"
686architecture = "arm64"
687capabilities = [
688  { interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.fixture.post-download" },
689  { interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.fixture.post-download" },
690]
691"#,
692        );
693        assert!(matches!(
694            PluginProjectManifest::from_toml(&duplicate),
695            Err(PluginProjectManifestError::InvalidField { ref field, ref message })
696                if field == "artifacts.capabilities" && message.contains("duplicate capability")
697        ));
698
699        let uncovered = project_toml(
700            r#"
701[[capabilities]]
702interface_id = "c7a69475-79b2-5b5e-a477-08844a5da5d1"
703instance_id = "dev.vesper.fixture.event-hook"
704interface_major = 1
705interface_minor = 0
706stability = "stable"
707
708[[artifacts]]
709transport = "native"
710target = "aarch64-apple-darwin"
711format = "dylib"
712source = "plugin.dylib"
713path = "artifacts/plugin.dylib"
714architecture = "arm64"
715capabilities = [{ interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.fixture.post-download" }]
716"#,
717        );
718        assert!(matches!(
719            PluginProjectManifest::from_toml(&uncovered),
720            Err(PluginProjectManifestError::InvalidField { ref field, ref message })
721                if field == "artifacts.capabilities"
722                    && message.contains("every descriptor capability")
723        ));
724    }
725
726    #[test]
727    fn project_rejects_file_and_directory_path_conflicts() {
728        let conflict = project_toml(
729            r#"
730[[package_files]]
731source = "node.json"
732path = "runtime/node"
733kind = "runtime-metadata"
734
735[[package_files]]
736source = "data.json"
737path = "runtime/node/data"
738kind = "runtime-metadata"
739"#,
740        );
741        assert!(matches!(
742            PluginProjectManifest::from_toml(&conflict),
743            Err(PluginProjectManifestError::ConflictingPackagePath {
744                ref path,
745                ref conflicting_path,
746            }) if path == "runtime/node/data" && conflicting_path == "runtime/node"
747        ));
748
749        let reserved_ancestor = project_toml(
750            r#"
751[[artifacts]]
752transport = "native"
753target = "aarch64-apple-darwin"
754format = "dylib"
755source = "plugin.dylib"
756path = "manifest.json/payload"
757architecture = "arm64"
758capabilities = [{ interface_id = "e9479dbc-42d2-575e-b39e-a24bc512fbc7", instance_id = "dev.vesper.fixture.post-download" }]
759"#,
760        );
761        assert!(matches!(
762            PluginProjectManifest::from_toml(&reserved_ancestor),
763            Err(PluginProjectManifestError::ConflictingPackagePath { .. })
764        ));
765    }
766
767    #[test]
768    fn project_rejects_unicode_normalization_and_casefold_path_collisions() {
769        let collision = project_toml(
770            r#"
771[[package_files]]
772source = "composed.txt"
773path = "licenses/É.txt"
774kind = "license"
775
776[[package_files]]
777source = "decomposed.txt"
778path = "licenses/É.txt"
779kind = "license"
780"#,
781        );
782        assert!(matches!(
783            PluginProjectManifest::from_toml(&collision),
784            Err(PluginProjectManifestError::DuplicatePackagePath { .. })
785        ));
786
787        let full_casefold_collision = project_toml(
788            r#"
789[[package_files]]
790source = "eszett.txt"
791path = "licenses/ß.txt"
792kind = "license"
793
794[[package_files]]
795source = "double-s.txt"
796path = "licenses/ss.txt"
797kind = "license"
798"#,
799        );
800        assert!(matches!(
801            PluginProjectManifest::from_toml(&full_casefold_collision),
802            Err(PluginProjectManifestError::DuplicatePackagePath { .. })
803        ));
804    }
805}