1use std::collections::HashSet;
8
9use player_plugin_abi::{VESPER_MAX_CAPABILITY_INSTANCE_ID_BYTES, VESPER_MAX_PLUGIN_ID_BYTES};
10use semver::Version;
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use sha2::{Digest, Sha256};
13use thiserror::Error;
14use uuid::Uuid;
15
16use crate::{PluginReference, PluginReferenceError, PluginTransport};
17
18pub const PLUGIN_CATALOG_SCHEMA_VERSION: u32 = 1;
20pub const PLUGIN_CATALOG_MIGRATION_VERSION: &str = "vesper-plugin-runtime-rewrite-v1";
22pub const MAX_PLUGIN_ARTIFACT_CAPABILITIES: usize = 64;
23pub const MAX_PLUGIN_REQUIREMENTS: usize = 64;
24pub const MAX_PLUGIN_PROVISIONS: usize = 64;
25pub const MAX_PLUGIN_CATALOG_RECORDS: usize = 1024;
26pub const MAX_PLUGIN_CATALOG_DIAGNOSTICS: usize = 64;
27pub const MAX_PLUGIN_TARGET_BYTES: usize = 128;
28pub const MAX_PLUGIN_ARCHITECTURE_BYTES: usize = 64;
29pub const MAX_PLUGIN_ARTIFACT_PATH_BYTES: usize = 4096;
30pub const MAX_PLUGIN_CATALOG_SOURCE_BYTES: usize = 256;
31pub const MAX_PLUGIN_RUNTIME_DEPENDENCIES: usize = 32;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum PluginArtifactTransport {
39 Native,
40 Wasm,
41}
42
43impl PluginArtifactTransport {
44 pub const fn as_str(self) -> &'static str {
45 match self {
46 Self::Native => "native",
47 Self::Wasm => "wasm",
48 }
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum PluginArtifactFormat {
56 Dylib,
57 Aar,
58 Xcframework,
59 WasmComponent,
60}
61
62impl PluginArtifactFormat {
63 pub const fn as_str(self) -> &'static str {
64 match self {
65 Self::Dylib => "dylib",
66 Self::Aar => "aar",
67 Self::Xcframework => "xcframework",
68 Self::WasmComponent => "wasm-component",
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct PluginArtifactCapability {
78 pub interface_id: String,
79 pub instance_id: String,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct PluginRequirement {
87 pub service: String,
88 pub requirement: String,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct PluginProvision {
95 pub service: String,
96 pub version: String,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum PluginRuntimeLinkage {
102 Dynamic,
103 Static,
104 System,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct PluginRuntimeDependency {
111 pub id: String,
112 pub version: String,
113 pub linkage: PluginRuntimeLinkage,
114 pub compatibility_key: String,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct PluginResourcePolicy {
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub max_memory_bytes: Option<u64>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub max_queue_depth: Option<u32>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub max_call_micros: Option<u64>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
132#[serde(rename_all = "kebab-case")]
133pub enum PluginCatalogSource {
134 Package,
135 Installed,
136 Embedded,
137 Development,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
142#[serde(deny_unknown_fields)]
143pub struct PluginArtifactDescriptor {
144 pub schema_version: u32,
145 pub plugin_id: String,
146 pub version: String,
147 pub publisher: String,
148 pub transport: PluginArtifactTransport,
149 pub target: String,
150 pub format: PluginArtifactFormat,
151 pub architecture: String,
152 pub abi_major: u16,
153 pub abi_minor_min: u16,
154 pub abi_minor_max: u16,
155 pub capabilities: Vec<PluginArtifactCapability>,
156 #[serde(default)]
157 pub requires: Vec<PluginRequirement>,
158 #[serde(default)]
159 pub provides: Vec<PluginProvision>,
160 #[serde(default)]
161 pub runtime_dependencies: Vec<PluginRuntimeDependency>,
162 #[serde(default)]
163 pub resource_policy: PluginResourcePolicy,
164 #[serde(default = "default_migration_version")]
165 pub migration_version: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
171#[serde(deny_unknown_fields)]
172pub struct PluginCatalogRecord {
173 pub schema_version: u32,
174 pub descriptor: PluginArtifactDescriptor,
175 pub artifact_path: String,
176 pub artifact_sha256: String,
177 pub source: PluginCatalogSource,
178 #[serde(default)]
179 pub diagnostics: Vec<PluginCatalogDiagnostic>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(deny_unknown_fields)]
185pub struct PluginCatalogDiagnostic {
186 pub code: String,
187 pub message: String,
188}
189
190#[derive(Debug, Deserialize)]
191#[serde(deny_unknown_fields)]
192struct PluginArtifactDescriptorWire {
193 schema_version: u32,
194 plugin_id: String,
195 version: String,
196 publisher: String,
197 transport: PluginArtifactTransport,
198 target: String,
199 format: PluginArtifactFormat,
200 architecture: String,
201 abi_major: u16,
202 abi_minor_min: u16,
203 abi_minor_max: u16,
204 capabilities: Vec<PluginArtifactCapability>,
205 #[serde(default)]
206 requires: Vec<PluginRequirement>,
207 #[serde(default)]
208 provides: Vec<PluginProvision>,
209 #[serde(default)]
210 runtime_dependencies: Vec<PluginRuntimeDependency>,
211 #[serde(default)]
212 resource_policy: PluginResourcePolicy,
213 #[serde(default = "default_migration_version")]
214 migration_version: String,
215}
216
217#[derive(Debug, Deserialize)]
218#[serde(deny_unknown_fields)]
219struct PluginCatalogRecordWire {
220 schema_version: u32,
221 descriptor: PluginArtifactDescriptor,
222 artifact_path: String,
223 artifact_sha256: String,
224 source: PluginCatalogSource,
225 #[serde(default)]
226 diagnostics: Vec<PluginCatalogDiagnostic>,
227}
228
229impl<'de> Deserialize<'de> for PluginArtifactDescriptor {
230 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
231 where
232 D: Deserializer<'de>,
233 {
234 let wire = PluginArtifactDescriptorWire::deserialize(deserializer)?;
235 let descriptor = Self {
236 schema_version: wire.schema_version,
237 plugin_id: wire.plugin_id,
238 version: wire.version,
239 publisher: wire.publisher,
240 transport: wire.transport,
241 target: wire.target,
242 format: wire.format,
243 architecture: wire.architecture,
244 abi_major: wire.abi_major,
245 abi_minor_min: wire.abi_minor_min,
246 abi_minor_max: wire.abi_minor_max,
247 capabilities: wire.capabilities,
248 requires: wire.requires,
249 provides: wire.provides,
250 runtime_dependencies: wire.runtime_dependencies,
251 resource_policy: wire.resource_policy,
252 migration_version: wire.migration_version,
253 };
254 descriptor.validate().map_err(serde::de::Error::custom)?;
255 Ok(descriptor)
256 }
257}
258
259impl<'de> Deserialize<'de> for PluginCatalogRecord {
260 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
261 where
262 D: Deserializer<'de>,
263 {
264 let wire = PluginCatalogRecordWire::deserialize(deserializer)?;
265 let record = Self {
266 schema_version: wire.schema_version,
267 descriptor: wire.descriptor,
268 artifact_path: wire.artifact_path,
269 artifact_sha256: wire.artifact_sha256,
270 source: wire.source,
271 diagnostics: wire.diagnostics,
272 };
273 record.validate().map_err(serde::de::Error::custom)?;
274 Ok(record)
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct CanonicalPluginArtifactDescriptor {
280 descriptor: PluginArtifactDescriptor,
281 json: Vec<u8>,
282 sha256: String,
283}
284
285impl CanonicalPluginArtifactDescriptor {
286 pub fn descriptor(&self) -> &PluginArtifactDescriptor {
287 &self.descriptor
288 }
289
290 pub fn json(&self) -> &[u8] {
291 &self.json
292 }
293
294 pub fn sha256(&self) -> &str {
295 &self.sha256
296 }
297
298 pub fn fingerprint(&self) -> &str {
299 self.sha256()
300 }
301}
302
303#[derive(Debug, Error, Clone, PartialEq, Eq)]
304pub enum PluginCatalogError {
305 #[error("invalid plugin catalog field `{field}`: {message}")]
306 InvalidField { field: String, message: String },
307 #[error("duplicate artifact capability `{interface_id}:{instance_id}`")]
308 DuplicateCapability {
309 interface_id: String,
310 instance_id: String,
311 },
312 #[error("duplicate catalog identity `{identity}` from `{first_path}` and `{duplicate_path}`")]
313 DuplicateIdentity {
314 identity: String,
315 first_path: String,
316 duplicate_path: String,
317 },
318 #[error("artifact digest for `{path}` is not valid SHA-256")]
319 InvalidDigest { path: String },
320 #[error("catalog contains more than {limit} records")]
321 TooManyRecords { limit: usize },
322 #[error("catalog contains more than {limit} diagnostics")]
323 TooManyDiagnostics { limit: usize },
324 #[error("failed to serialize canonical plugin catalog metadata: {0}")]
325 Json(String),
326 #[error(transparent)]
327 Reference(#[from] PluginReferenceError),
328}
329
330impl PluginArtifactDescriptor {
331 #[allow(clippy::too_many_arguments)]
335 pub fn from_parts(
336 plugin_id: impl Into<String>,
337 version: impl Into<String>,
338 publisher: impl Into<String>,
339 transport: PluginArtifactTransport,
340 target: impl Into<String>,
341 format: PluginArtifactFormat,
342 architecture: impl Into<String>,
343 abi_major: u16,
344 abi_minor_min: u16,
345 abi_minor_max: u16,
346 capabilities: Vec<PluginArtifactCapability>,
347 ) -> Result<Self, PluginCatalogError> {
348 let descriptor = Self {
349 schema_version: PLUGIN_CATALOG_SCHEMA_VERSION,
350 plugin_id: plugin_id.into(),
351 version: version.into(),
352 publisher: publisher.into(),
353 transport,
354 target: target.into(),
355 format,
356 architecture: architecture.into(),
357 abi_major,
358 abi_minor_min,
359 abi_minor_max,
360 capabilities,
361 requires: Vec::new(),
362 provides: Vec::new(),
363 runtime_dependencies: Vec::new(),
364 resource_policy: PluginResourcePolicy::default(),
365 migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
366 };
367 descriptor.validate()?;
368 Ok(descriptor)
369 }
370
371 pub fn from_json(bytes: &[u8]) -> Result<Self, PluginCatalogError> {
373 let descriptor: Self = serde_json::from_slice(bytes)
374 .map_err(|error| PluginCatalogError::Json(error.to_string()))?;
375 descriptor.validate()?;
376 Ok(descriptor)
377 }
378
379 pub fn to_json(&self) -> Result<Vec<u8>, PluginCatalogError> {
381 Ok(self.canonicalize()?.json().to_vec())
382 }
383
384 pub fn validate(&self) -> Result<(), PluginCatalogError> {
386 if self.schema_version != PLUGIN_CATALOG_SCHEMA_VERSION {
387 return invalid(
388 "schema_version",
389 format!(
390 "expected {PLUGIN_CATALOG_SCHEMA_VERSION}, got {}",
391 self.schema_version
392 ),
393 );
394 }
395 validate_reverse_dns("plugin_id", &self.plugin_id, VESPER_MAX_PLUGIN_ID_BYTES)?;
396 validate_reverse_dns("publisher", &self.publisher, VESPER_MAX_PLUGIN_ID_BYTES)?;
397 Version::parse(&self.version).map_err(|error| field_error("version", error.to_string()))?;
398 validate_text("target", &self.target, MAX_PLUGIN_TARGET_BYTES)?;
399 validate_text(
400 "architecture",
401 &self.architecture,
402 MAX_PLUGIN_ARCHITECTURE_BYTES,
403 )?;
404 if self.abi_major == 0 {
405 return invalid("abi_major", "must be greater than zero");
406 }
407 if self.abi_minor_min > self.abi_minor_max {
408 return invalid("abi_minor_min", "must not exceed abi_minor_max");
409 }
410 if !format_matches_transport(self.transport, self.format) {
411 return invalid(
412 "format",
413 format!(
414 "format '{}' is incompatible with transport '{}'",
415 self.format.as_str(),
416 transport_name(self.transport)
417 ),
418 );
419 }
420 if self.capabilities.is_empty()
421 || self.capabilities.len() > MAX_PLUGIN_ARTIFACT_CAPABILITIES
422 {
423 return invalid(
424 "capabilities",
425 format!("must contain 1 to {MAX_PLUGIN_ARTIFACT_CAPABILITIES} entries"),
426 );
427 }
428 let mut identities = HashSet::with_capacity(self.capabilities.len());
429 for capability in &self.capabilities {
430 let interface_id = Uuid::parse_str(&capability.interface_id)
431 .map_err(|error| field_error("capabilities.interface_id", error.to_string()))?;
432 if interface_id.hyphenated().to_string() != capability.interface_id {
433 return invalid(
434 "capabilities.interface_id",
435 "must use canonical lowercase hyphenated UUID form",
436 );
437 }
438 validate_reverse_dns(
439 "capabilities.instance_id",
440 &capability.instance_id,
441 VESPER_MAX_CAPABILITY_INSTANCE_ID_BYTES,
442 )?;
443 if !identities.insert((&capability.interface_id, &capability.instance_id)) {
444 return Err(PluginCatalogError::DuplicateCapability {
445 interface_id: capability.interface_id.clone(),
446 instance_id: capability.instance_id.clone(),
447 });
448 }
449 }
450 validate_dependency_declarations(&self.requires, "requires")?;
451 validate_provisions(&self.provides)?;
452 validate_runtime_dependencies(&self.runtime_dependencies)?;
453 validate_text(
454 "migration_version",
455 &self.migration_version,
456 MAX_PLUGIN_CATALOG_SOURCE_BYTES,
457 )?;
458 validate_resource_policy(&self.resource_policy)?;
459 Ok(())
460 }
461
462 pub fn canonicalize(&self) -> Result<CanonicalPluginArtifactDescriptor, PluginCatalogError> {
464 self.validate()?;
465 let mut descriptor = self.clone();
466 descriptor.capabilities.sort();
467 descriptor.requires.sort();
468 descriptor.provides.sort();
469 descriptor.runtime_dependencies.sort();
470 let json = serde_json::to_vec(&descriptor)
471 .map_err(|error| PluginCatalogError::Json(error.to_string()))?;
472 let sha256 = hex::encode(Sha256::digest(&json));
473 Ok(CanonicalPluginArtifactDescriptor {
474 descriptor,
475 json,
476 sha256,
477 })
478 }
479
480 pub fn fingerprint(&self) -> Result<String, PluginCatalogError> {
481 Ok(self.canonicalize()?.sha256().to_owned())
482 }
483
484 pub fn plugin_reference(
485 &self,
486 capability_instance_id: Option<String>,
487 ) -> Result<PluginReference, PluginCatalogError> {
488 self.validate()?;
489 Ok(PluginReference::new(
490 self.plugin_id.clone(),
491 capability_instance_id,
492 match self.transport {
493 PluginArtifactTransport::Native => PluginTransport::Native,
494 PluginArtifactTransport::Wasm => PluginTransport::Wasm,
495 },
496 )?)
497 }
498}
499
500impl PluginCatalogRecord {
501 pub fn from_descriptor(
502 descriptor: PluginArtifactDescriptor,
503 artifact_path: impl Into<String>,
504 artifact_sha256: impl Into<String>,
505 source: PluginCatalogSource,
506 ) -> Result<Self, PluginCatalogError> {
507 Self::new(descriptor, artifact_path, artifact_sha256, source)
508 }
509
510 pub fn new(
511 descriptor: PluginArtifactDescriptor,
512 artifact_path: impl Into<String>,
513 artifact_sha256: impl Into<String>,
514 source: PluginCatalogSource,
515 ) -> Result<Self, PluginCatalogError> {
516 let record = Self {
517 schema_version: PLUGIN_CATALOG_SCHEMA_VERSION,
518 descriptor,
519 artifact_path: artifact_path.into(),
520 artifact_sha256: artifact_sha256.into(),
521 source,
522 diagnostics: Vec::new(),
523 };
524 record.validate()?;
525 Ok(record)
526 }
527
528 pub fn from_json(bytes: &[u8]) -> Result<Self, PluginCatalogError> {
530 let record: Self = serde_json::from_slice(bytes)
531 .map_err(|error| PluginCatalogError::Json(error.to_string()))?;
532 record.validate()?;
533 Ok(record)
534 }
535
536 pub fn validate(&self) -> Result<(), PluginCatalogError> {
537 if self.schema_version != PLUGIN_CATALOG_SCHEMA_VERSION {
538 return invalid(
539 "schema_version",
540 format!(
541 "expected {PLUGIN_CATALOG_SCHEMA_VERSION}, got {}",
542 self.schema_version
543 ),
544 );
545 }
546 self.descriptor.validate()?;
547 validate_text(
548 "artifact_path",
549 &self.artifact_path,
550 MAX_PLUGIN_ARTIFACT_PATH_BYTES,
551 )?;
552 if self.artifact_path.contains('\0') {
553 return invalid("artifact_path", "must not contain NUL bytes");
554 }
555 if self.source == PluginCatalogSource::Package && !is_safe_package_path(&self.artifact_path)
556 {
557 return invalid(
558 "artifact_path",
559 "package catalog paths must be relative and must not contain traversal segments",
560 );
561 }
562 if !is_sha256(&self.artifact_sha256) {
563 return Err(PluginCatalogError::InvalidDigest {
564 path: self.artifact_path.clone(),
565 });
566 }
567 if self.diagnostics.len() > MAX_PLUGIN_CATALOG_DIAGNOSTICS {
568 return Err(PluginCatalogError::TooManyDiagnostics {
569 limit: MAX_PLUGIN_CATALOG_DIAGNOSTICS,
570 });
571 }
572 for diagnostic in &self.diagnostics {
573 validate_text("diagnostics.code", &diagnostic.code, 128)?;
574 validate_text("diagnostics.message", &diagnostic.message, 512)?;
575 }
576 Ok(())
577 }
578
579 pub fn descriptor(&self) -> &PluginArtifactDescriptor {
580 &self.descriptor
581 }
582
583 pub fn artifact_path(&self) -> &str {
584 &self.artifact_path
585 }
586
587 pub fn artifact_sha256(&self) -> &str {
588 &self.artifact_sha256
589 }
590
591 pub fn identity_key(&self) -> String {
592 format!(
593 "{}:{}:{}:{}:{}:{}",
594 transport_name(self.descriptor.transport),
595 self.descriptor.plugin_id,
596 self.descriptor.version,
597 self.descriptor.target,
598 self.descriptor.architecture,
599 self.descriptor.format.as_str()
600 )
601 }
602
603 pub fn canonical_identity_key(&self) -> String {
609 [
610 transport_name(self.descriptor.transport),
611 &self.descriptor.plugin_id,
612 &self.descriptor.version,
613 &self.descriptor.target,
614 &self.descriptor.architecture,
615 self.descriptor.format.as_str(),
616 ]
617 .into_iter()
618 .map(|component| format!("{}:{component}", component.len()))
619 .collect::<Vec<_>>()
620 .join("|")
621 }
622
623 pub fn canonicalize(&self) -> Result<Vec<u8>, PluginCatalogError> {
624 self.validate()?;
625 let mut record = self.clone();
626 record.descriptor = record.descriptor.canonicalize()?.descriptor().clone();
627 record
628 .diagnostics
629 .sort_by(|left, right| (&left.code, &left.message).cmp(&(&right.code, &right.message)));
630 serde_json::to_vec(&record).map_err(|error| PluginCatalogError::Json(error.to_string()))
631 }
632
633 pub fn fingerprint(&self) -> Result<String, PluginCatalogError> {
634 Ok(hex::encode(Sha256::digest(self.canonicalize()?)))
635 }
636}
637
638#[derive(Debug, Clone, PartialEq, Eq)]
641pub struct PluginCatalog {
642 records: Vec<PluginCatalogRecord>,
643 fingerprint: String,
644}
645
646impl Serialize for PluginCatalog {
647 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
648 where
649 S: Serializer,
650 {
651 self.records.serialize(serializer)
652 }
653}
654
655impl<'de> Deserialize<'de> for PluginCatalog {
656 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
657 where
658 D: Deserializer<'de>,
659 {
660 let records = Vec::<PluginCatalogRecord>::deserialize(deserializer)?;
661 Self::from_records(records).map_err(serde::de::Error::custom)
662 }
663}
664
665impl PluginCatalog {
666 pub fn new(
667 records: impl IntoIterator<Item = PluginCatalogRecord>,
668 ) -> Result<Self, PluginCatalogError> {
669 Self::from_records(records)
670 }
671
672 pub fn from_records(
673 records: impl IntoIterator<Item = PluginCatalogRecord>,
674 ) -> Result<Self, PluginCatalogError> {
675 let mut normalized = Vec::new();
676 for record in records {
677 if normalized.len() >= MAX_PLUGIN_CATALOG_RECORDS {
678 return Err(PluginCatalogError::TooManyRecords {
679 limit: MAX_PLUGIN_CATALOG_RECORDS,
680 });
681 }
682 record.validate()?;
683 let mut record = record;
684 record.descriptor = record.descriptor.canonicalize()?.descriptor().clone();
685 record.diagnostics.sort_by(|left, right| {
686 (&left.code, &left.message).cmp(&(&right.code, &right.message))
687 });
688 normalized.push(record);
689 }
690 let mut records = normalized;
691 records.sort_by(|left, right| {
692 left.identity_key()
693 .cmp(&right.identity_key())
694 .then_with(|| {
695 left.canonical_identity_key()
696 .cmp(&right.canonical_identity_key())
697 })
698 });
699 for pair in records.windows(2) {
700 if pair[0].canonical_identity_key() == pair[1].canonical_identity_key() {
701 return Err(PluginCatalogError::DuplicateIdentity {
702 identity: pair[0].identity_key(),
703 first_path: pair[0].artifact_path.clone(),
704 duplicate_path: pair[1].artifact_path.clone(),
705 });
706 }
707 }
708 let bytes = serde_json::to_vec(&records)
709 .map_err(|error| PluginCatalogError::Json(error.to_string()))?;
710 let fingerprint = hex::encode(Sha256::digest(bytes));
711 Ok(Self {
712 records,
713 fingerprint,
714 })
715 }
716
717 pub fn empty() -> Self {
718 Self {
719 records: Vec::new(),
720 fingerprint: hex::encode(Sha256::digest(b"[]")),
721 }
722 }
723
724 pub fn from_json(bytes: &[u8]) -> Result<Self, PluginCatalogError> {
727 let records: Vec<PluginCatalogRecord> = serde_json::from_slice(bytes)
728 .map_err(|error| PluginCatalogError::Json(error.to_string()))?;
729 Self::from_records(records)
730 }
731
732 pub fn to_json(&self) -> Result<Vec<u8>, PluginCatalogError> {
733 serde_json::to_vec(&self.records)
734 .map_err(|error| PluginCatalogError::Json(error.to_string()))
735 }
736
737 pub fn records(&self) -> &[PluginCatalogRecord] {
738 &self.records
739 }
740
741 pub fn len(&self) -> usize {
742 self.records.len()
743 }
744
745 pub fn is_empty(&self) -> bool {
746 self.records.is_empty()
747 }
748
749 pub fn fingerprint(&self) -> &str {
750 &self.fingerprint
751 }
752
753 pub fn find(&self, plugin_id: &str) -> impl Iterator<Item = &PluginCatalogRecord> {
754 self.records
755 .iter()
756 .filter(move |record| record.descriptor.plugin_id == plugin_id)
757 }
758}
759
760fn default_migration_version() -> String {
761 PLUGIN_CATALOG_MIGRATION_VERSION.to_owned()
762}
763
764fn format_matches_transport(
765 transport: PluginArtifactTransport,
766 format: PluginArtifactFormat,
767) -> bool {
768 matches!(
769 (transport, format),
770 (
771 PluginArtifactTransport::Wasm,
772 PluginArtifactFormat::WasmComponent
773 ) | (PluginArtifactTransport::Native, PluginArtifactFormat::Dylib)
774 | (PluginArtifactTransport::Native, PluginArtifactFormat::Aar)
775 | (
776 PluginArtifactTransport::Native,
777 PluginArtifactFormat::Xcframework
778 )
779 )
780}
781
782fn transport_name(transport: PluginArtifactTransport) -> &'static str {
783 match transport {
784 PluginArtifactTransport::Native => "native",
785 PluginArtifactTransport::Wasm => "wasm",
786 }
787}
788
789fn validate_dependency_declarations(
790 dependencies: &[PluginRequirement],
791 field: &str,
792) -> Result<(), PluginCatalogError> {
793 if dependencies.len() > MAX_PLUGIN_REQUIREMENTS {
794 return invalid(
795 field,
796 format!("must contain at most {MAX_PLUGIN_REQUIREMENTS} entries"),
797 );
798 }
799 let mut seen = HashSet::with_capacity(dependencies.len());
800 for dependency in dependencies {
801 validate_reverse_dns(
802 &format!("{field}.service"),
803 &dependency.service,
804 VESPER_MAX_PLUGIN_ID_BYTES,
805 )?;
806 semver::VersionReq::parse(&dependency.requirement).map_err(|error| {
807 field_error(
808 &format!("{field}.requirement"),
809 format!("invalid semver requirement: {error}"),
810 )
811 })?;
812 if !seen.insert(&dependency.service) {
813 return invalid(
814 &format!("{field}.service"),
815 "must not contain duplicate service identities",
816 );
817 }
818 }
819 Ok(())
820}
821
822pub fn validate_plugin_requirements(
825 requirements: &[PluginRequirement],
826) -> Result<(), PluginCatalogError> {
827 validate_dependency_declarations(requirements, "requires")
828}
829
830fn validate_provisions(provisions: &[PluginProvision]) -> Result<(), PluginCatalogError> {
831 if provisions.len() > MAX_PLUGIN_PROVISIONS {
832 return invalid(
833 "provides",
834 format!("must contain at most {MAX_PLUGIN_PROVISIONS} entries"),
835 );
836 }
837 let mut seen = HashSet::with_capacity(provisions.len());
838 for provision in provisions {
839 validate_reverse_dns(
840 "provides.service",
841 &provision.service,
842 VESPER_MAX_PLUGIN_ID_BYTES,
843 )?;
844 Version::parse(&provision.version).map_err(|error| {
845 field_error(
846 "provides.version",
847 format!("invalid semver version: {error}"),
848 )
849 })?;
850 if !seen.insert(&provision.service) {
851 return invalid(
852 "provides.service",
853 "must not contain duplicate service identities",
854 );
855 }
856 }
857 Ok(())
858}
859
860pub fn validate_plugin_provisions(
863 provisions: &[PluginProvision],
864) -> Result<(), PluginCatalogError> {
865 validate_provisions(provisions)
866}
867
868fn validate_runtime_dependencies(
869 dependencies: &[PluginRuntimeDependency],
870) -> Result<(), PluginCatalogError> {
871 if dependencies.len() > MAX_PLUGIN_RUNTIME_DEPENDENCIES {
872 return invalid(
873 "runtime_dependencies",
874 format!("must contain at most {MAX_PLUGIN_RUNTIME_DEPENDENCIES} entries"),
875 );
876 }
877 let mut seen = HashSet::with_capacity(dependencies.len());
878 for dependency in dependencies {
879 validate_reverse_dns(
880 "runtime_dependencies.id",
881 &dependency.id,
882 VESPER_MAX_PLUGIN_ID_BYTES,
883 )?;
884 validate_text(
885 "runtime_dependencies.version",
886 &dependency.version,
887 MAX_PLUGIN_CATALOG_SOURCE_BYTES,
888 )?;
889 validate_text(
890 "runtime_dependencies.compatibility_key",
891 &dependency.compatibility_key,
892 MAX_PLUGIN_CATALOG_SOURCE_BYTES,
893 )?;
894 if !seen.insert(&dependency.id) {
895 return invalid(
896 "runtime_dependencies.id",
897 "must not contain duplicate dependency identities",
898 );
899 }
900 }
901 Ok(())
902}
903
904fn validate_resource_policy(policy: &PluginResourcePolicy) -> Result<(), PluginCatalogError> {
905 if policy.max_memory_bytes == Some(0)
906 || policy.max_queue_depth == Some(0)
907 || policy.max_call_micros == Some(0)
908 {
909 return invalid("resource_policy", "limits must be greater than zero");
910 }
911 Ok(())
912}
913
914fn validate_reverse_dns(
915 field: &str,
916 value: &str,
917 maximum_bytes: usize,
918) -> Result<(), PluginCatalogError> {
919 PluginReference::new(value.to_owned(), None, PluginTransport::Native)
920 .map(|_| ())
921 .map_err(|error| {
922 field_error(
923 field,
924 format!("must be a valid reverse-DNS identity: {error}"),
925 )
926 })?;
927 if value.len() > maximum_bytes {
928 return invalid(
929 field,
930 format!("must not exceed {maximum_bytes} UTF-8 bytes"),
931 );
932 }
933 Ok(())
934}
935
936fn validate_text(field: &str, value: &str, maximum_bytes: usize) -> Result<(), PluginCatalogError> {
937 if value.is_empty() || value.len() > maximum_bytes {
938 return invalid(
939 field,
940 format!("must contain 1 to {maximum_bytes} UTF-8 bytes"),
941 );
942 }
943 Ok(())
944}
945
946fn is_sha256(value: &str) -> bool {
947 value.len() == 64
948 && value
949 .bytes()
950 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
951}
952
953fn is_safe_package_path(value: &str) -> bool {
954 !value.starts_with('/')
955 && !value.ends_with('/')
956 && !value.contains('\\')
957 && !value.contains("//")
958 && value
959 .split('/')
960 .all(|segment| !segment.is_empty() && segment != "." && segment != "..")
961}
962
963fn invalid<T>(field: &str, message: impl Into<String>) -> Result<T, PluginCatalogError> {
964 Err(field_error(field, message))
965}
966
967fn field_error(field: &str, message: impl Into<String>) -> PluginCatalogError {
968 PluginCatalogError::InvalidField {
969 field: field.to_owned(),
970 message: message.into(),
971 }
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977
978 fn descriptor() -> PluginArtifactDescriptor {
979 PluginArtifactDescriptor {
980 schema_version: PLUGIN_CATALOG_SCHEMA_VERSION,
981 plugin_id: "dev.vesper.catalog-fixture".to_owned(),
982 version: "1.2.3".to_owned(),
983 publisher: "dev.vesper.publisher".to_owned(),
984 transport: PluginArtifactTransport::Native,
985 target: "aarch64-apple-darwin".to_owned(),
986 format: PluginArtifactFormat::Dylib,
987 architecture: "arm64".to_owned(),
988 abi_major: 1,
989 abi_minor_min: 0,
990 abi_minor_max: 0,
991 capabilities: vec![PluginArtifactCapability {
992 interface_id: "e9479dbc-42d2-575e-b39e-a24bc512fbc7".to_owned(),
993 instance_id: "dev.vesper.catalog-fixture.primary".to_owned(),
994 }],
995 requires: Vec::new(),
996 provides: Vec::new(),
997 runtime_dependencies: Vec::new(),
998 resource_policy: PluginResourcePolicy::default(),
999 migration_version: PLUGIN_CATALOG_MIGRATION_VERSION.to_owned(),
1000 }
1001 }
1002
1003 #[test]
1004 fn descriptor_canonicalization_is_order_independent_and_pure() {
1005 let mut left = descriptor();
1006 let mut right = descriptor();
1007 left.capabilities.push(PluginArtifactCapability {
1008 interface_id: "c7a69475-79b2-5b5e-a477-08844a5da5d1".to_owned(),
1009 instance_id: "dev.vesper.catalog-fixture.secondary".to_owned(),
1010 });
1011 right.capabilities = left.capabilities.iter().cloned().rev().collect();
1012 assert_eq!(
1013 left.canonicalize().expect("left canonical").sha256(),
1014 right.canonicalize().expect("right canonical").sha256()
1015 );
1016 }
1017
1018 #[test]
1019 fn catalog_rejects_duplicate_identity_without_live_state() {
1020 let first = PluginCatalogRecord::new(
1021 descriptor(),
1022 "artifacts/first.dylib",
1023 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1024 PluginCatalogSource::Package,
1025 )
1026 .expect("first record");
1027 let second = PluginCatalogRecord::new(
1028 descriptor(),
1029 "artifacts/second.dylib",
1030 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1031 PluginCatalogSource::Package,
1032 )
1033 .expect("second record");
1034 assert!(matches!(
1035 PluginCatalog::from_records([first, second]),
1036 Err(PluginCatalogError::DuplicateIdentity { .. })
1037 ));
1038 }
1039
1040 #[test]
1041 fn json_entrypoints_validate_metadata_and_rebuild_the_same_fingerprint() {
1042 let record = PluginCatalogRecord::new(
1043 descriptor(),
1044 "artifacts/fixture.dylib",
1045 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1046 PluginCatalogSource::Package,
1047 )
1048 .expect("record");
1049 let catalog = PluginCatalog::from_records([record]).expect("catalog");
1050 let bytes = catalog.to_json().expect("catalog json");
1051 let json: serde_json::Value = serde_json::from_slice(&bytes).expect("json value");
1052 assert!(
1053 json[0]["descriptor"]["resource_policy"]
1054 .as_object()
1055 .expect("resource policy object")
1056 .values()
1057 .all(|value| !value.is_null())
1058 );
1059 let rebuilt = PluginCatalog::from_json(&bytes).expect("rebuilt catalog");
1060 assert_eq!(catalog.fingerprint(), rebuilt.fingerprint());
1061 let generic = serde_json::from_slice::<PluginCatalog>(&bytes).expect("generic catalog");
1062 assert_eq!(catalog.fingerprint(), generic.fingerprint());
1063
1064 let mut invalid = bytes;
1065 invalid.extend_from_slice(b" ");
1066 let decoded = PluginCatalog::from_json(&invalid).expect("JSON whitespace is harmless");
1067 assert_eq!(decoded.fingerprint(), catalog.fingerprint());
1068 }
1069
1070 #[test]
1071 fn package_records_reject_path_traversal_before_catalog_insertion() {
1072 let error = PluginCatalogRecord::new(
1073 descriptor(),
1074 "../escape.dylib",
1075 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1076 PluginCatalogSource::Package,
1077 )
1078 .expect_err("package traversal path");
1079 assert!(
1080 matches!(error, PluginCatalogError::InvalidField { ref field, .. } if field == "artifact_path")
1081 );
1082 }
1083
1084 #[test]
1085 fn runtime_dependency_metadata_is_retained_and_validated() {
1086 let mut descriptor = descriptor();
1087 descriptor
1088 .runtime_dependencies
1089 .push(PluginRuntimeDependency {
1090 id: "dev.vesper.runtime".to_owned(),
1091 version: "1.0.0".to_owned(),
1092 linkage: PluginRuntimeLinkage::Dynamic,
1093 compatibility_key: "darwin-arm64".to_owned(),
1094 });
1095 let json = descriptor.to_json().expect("descriptor json");
1096 let decoded = PluginArtifactDescriptor::from_json(&json).expect("descriptor");
1097 assert_eq!(decoded.runtime_dependencies.len(), 1);
1098 assert_eq!(decoded.runtime_dependencies[0].id, "dev.vesper.runtime");
1099 }
1100
1101 #[test]
1102 fn serde_deserialization_cannot_bypass_descriptor_validation() {
1103 let mut value = serde_json::to_value(descriptor()).expect("descriptor value");
1104 value["plugin_id"] = serde_json::json!("Invalid.Plugin");
1105 let error = serde_json::from_value::<PluginArtifactDescriptor>(value)
1106 .expect_err("invalid identity");
1107 assert!(error.to_string().contains("reverse-DNS"));
1108 }
1109
1110 #[test]
1111 fn canonical_identity_key_separates_opaque_colon_fields() {
1112 let mut left = descriptor();
1113 left.target = "target:one".to_owned();
1114 left.architecture = "arch".to_owned();
1115 let mut right = descriptor();
1116 right.target = "target".to_owned();
1117 right.architecture = "one:arch".to_owned();
1118 let left = PluginCatalogRecord::new(
1119 left,
1120 "artifacts/left.dylib",
1121 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1122 PluginCatalogSource::Package,
1123 )
1124 .expect("left record");
1125 let right = PluginCatalogRecord::new(
1126 right,
1127 "artifacts/right.dylib",
1128 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
1129 PluginCatalogSource::Package,
1130 )
1131 .expect("right record");
1132 assert_eq!(left.identity_key(), right.identity_key());
1133 assert_ne!(
1134 left.canonical_identity_key(),
1135 right.canonical_identity_key()
1136 );
1137 let catalog = PluginCatalog::from_records([left, right]).expect("distinct identities");
1138 assert_eq!(catalog.len(), 2);
1139 }
1140}