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#[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 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}