Skip to main content

player_plugin/
catalog.rs

1//! Pure plugin artifact and catalog metadata.
2//!
3//! The catalog layer deliberately contains no executable plugin state.  It is
4//! safe to serialize, sort, cache, and rebuild these values before a runtime
5//! decides to load an artifact.
6
7use 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
18/// Version of the pure artifact/catalog wire model.
19pub const PLUGIN_CATALOG_SCHEMA_VERSION: u32 = 1;
20/// Migration guide identity carried by new catalog records.
21pub 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/// Storage transport used by an artifact package.  This is kept separate from
34/// capability selection's [`PluginTransport`] so package provenance cannot be
35/// mistaken for workload support.
36#[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/// Artifact packaging format independent of the loader implementation.
53#[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/// A capability exposed by one artifact.  The descriptor owns the interface
74/// identity; executable availability is established later by the runtime.
75#[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/// A typed provider declaration.  Resolution is intentionally outside the
83/// catalog; this value only records the author-owned requirement.
84#[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/// A typed service/capability provided by an artifact.
92#[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/// A native/runtime dependency declaration preserved as catalog metadata.
108#[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/// Bounded resource declarations used by a future resolver policy.
118#[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/// Why a catalog record came from a particular source.  This is provenance,
130/// not a transport or capability claim.
131#[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/// Artifact metadata that can be inspected without opening the artifact.
141#[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/// A catalog record adds immutable artifact provenance and content identity to
169/// [`PluginArtifactDescriptor`].  It intentionally has no live owner field.
170#[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/// Bounded, redacted provenance diagnostic kept with a catalog record.
183#[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    /// Constructs and validates a descriptor from the stable artifact fields.
332    /// Optional dependency/resource declarations can be added before calling
333    /// [`Self::canonicalize`].
334    #[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    /// Decodes and validates one descriptor from canonical JSON input.
372    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    /// Encodes the validated descriptor in deterministic JSON form.
380    pub fn to_json(&self) -> Result<Vec<u8>, PluginCatalogError> {
381        Ok(self.canonicalize()?.json().to_vec())
382    }
383
384    /// Validates the descriptor without loading or probing an artifact.
385    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    /// Produces stable JSON and a SHA-256 identity for catalog metadata.
463    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    /// Decodes and validates one catalog record from JSON input.
529    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    /// Returns an unambiguous key for sorting and indexing catalog records.
604    ///
605    /// The human-readable [`Self::identity_key`] is retained for diagnostics;
606    /// this key length-prefixes every component so opaque target labels may
607    /// contain the diagnostic separator without colliding.
608    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/// Immutable catalog projection.  Construction validates and sorts records;
639/// querying it cannot load or instantiate a plugin.
640#[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    /// Decodes a complete catalog snapshot and rebuilds its deterministic
725    /// index/fingerprint.  No executable artifact is touched.
726    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
822/// Validates author-facing `requires` declarations using the catalog's
823/// canonical identity and semver rules.
824pub 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
860/// Validates author-facing `provides` declarations using the catalog's
861/// canonical identity and semver rules.
862pub 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}