Skip to main content

nemo_relay/plugin/dynamic/
manifest.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use serde::de::{self, Deserializer};
9use serde::ser::Serializer;
10use serde::{Deserialize, Serialize};
11
12use super::{
13    DYNAMIC_PLUGIN_MANIFEST_FILENAME, DynamicPluginCapability, DynamicPluginCheckState,
14    DynamicPluginCompatibility, DynamicPluginId, DynamicPluginKind, DynamicPluginLoadContract,
15    DynamicPluginMetadata, DynamicPluginRecord, DynamicPluginRustCompatibility,
16    DynamicPluginRustLoadContract, DynamicPluginSource, DynamicPluginSpec, DynamicPluginStatus,
17    DynamicPluginValidationStatus, DynamicPluginWorkerCompatibility,
18    DynamicPluginWorkerLoadContract, WorkerRuntime, current_timestamp,
19};
20use crate::plugin::{PluginError, Result};
21
22const SUPPORTED_WORKER_PROTOCOL: &str = "grpc-v1";
23
24/// Authored `relay-plugin.toml` manifest.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27pub struct DynamicPluginManifest {
28    /// Relay plugin manifest schema version.
29    pub manifest_version: u32,
30    /// Plugin identity and lane declaration.
31    pub plugin: DynamicPluginManifestPlugin,
32    /// Relay compatibility declarations.
33    pub compat: DynamicPluginManifestCompat,
34    /// Default desired-state settings.
35    pub defaults: DynamicPluginManifestDefaults,
36    /// Required capability declarations.
37    pub capabilities: DynamicPluginManifestCapabilities,
38    /// Optional static configuration schema reference.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub config_schema: Option<DynamicPluginManifestConfigSchema>,
41    /// Runtime load contract.
42    #[cfg_attr(feature = "schema", schemars(with = "DynamicPluginManifestLoadSchema"))]
43    pub load: DynamicPluginManifestLoad,
44    /// Optional source-oriented author metadata.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub source: Option<DynamicPluginManifestSource>,
47    /// Optional integrity/authenticity evidence references.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub integrity: Option<DynamicPluginManifestIntegrity>,
50    /// Optional human-oriented description.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub description: Option<String>,
53}
54
55/// Plugin identity block for `relay-plugin.toml`.
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub struct DynamicPluginManifestPlugin {
59    /// Stable plugin identifier.
60    pub id: DynamicPluginId,
61    /// Optional display name.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub name: Option<String>,
64    /// Optional plugin version.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub version: Option<String>,
67    /// Execution lane.
68    pub kind: DynamicPluginKind,
69}
70
71/// Compatibility block for `relay-plugin.toml`.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74pub struct DynamicPluginManifestCompat {
75    /// Supported Relay version or version range.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub relay: Option<String>,
78    /// Native plugin contract version.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub native_api: Option<String>,
81    /// Worker protocol version.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub worker_protocol: Option<String>,
84}
85
86/// Defaults block for authored manifests.
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89pub struct DynamicPluginManifestDefaults {
90    /// Explicit default desired enabled state.
91    #[serde(default)]
92    pub enabled: bool,
93}
94
95/// Capability declarations for authored manifests.
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
98pub struct DynamicPluginManifestCapabilities {
99    /// Required capability set.
100    pub items: Vec<DynamicPluginCapability>,
101}
102
103/// Static configuration schema block for authored manifests.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct DynamicPluginManifestConfigSchema {
107    /// Local JSON Schema path, resolved relative to `relay-plugin.toml`.
108    pub path: String,
109}
110
111/// Load block for authored manifests.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum DynamicPluginManifestLoad {
114    /// Worker registration target.
115    Worker(DynamicPluginManifestWorkerLoad),
116    /// Native shared-library registration target.
117    RustDynamic(DynamicPluginManifestRustDynamicLoad),
118}
119
120/// Worker lane authored load block.
121#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct DynamicPluginManifestWorkerLoad {
124    /// Worker runtime when `kind = "worker"`.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub runtime: Option<WorkerRuntime>,
127    /// Worker entrypoint.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub entrypoint: Option<String>,
130}
131
132/// Native shared-library authored load block.
133#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
134#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
135pub struct DynamicPluginManifestRustDynamicLoad {
136    /// Native library path.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub library: Option<String>,
139    /// Native registration symbol.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub symbol: Option<String>,
142}
143
144#[cfg(feature = "schema")]
145#[derive(schemars::JsonSchema)]
146#[allow(
147    dead_code,
148    reason = "variants are consumed by schemars derive metadata"
149)]
150#[serde(untagged)]
151enum DynamicPluginManifestLoadSchema {
152    Worker(DynamicPluginManifestWorkerLoad),
153    RustDynamic(DynamicPluginManifestRustDynamicLoad),
154}
155
156#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
157struct RawDynamicPluginManifestLoad {
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    runtime: Option<WorkerRuntime>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    entrypoint: Option<String>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    library: Option<String>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    symbol: Option<String>,
166}
167
168impl<'de> Deserialize<'de> for DynamicPluginManifestLoad {
169    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
170    where
171        D: Deserializer<'de>,
172    {
173        let raw = RawDynamicPluginManifestLoad::deserialize(deserializer)?;
174        let has_worker_fields = raw.runtime.is_some() || raw.entrypoint.is_some();
175        let has_native_fields = raw.library.is_some() || raw.symbol.is_some();
176
177        match (has_worker_fields, has_native_fields) {
178            (true, false) => Ok(Self::Worker(DynamicPluginManifestWorkerLoad {
179                runtime: raw.runtime,
180                entrypoint: raw.entrypoint,
181            })),
182            (false, true) => Ok(Self::RustDynamic(DynamicPluginManifestRustDynamicLoad {
183                library: raw.library,
184                symbol: raw.symbol,
185            })),
186            (true, true) => Err(de::Error::custom(
187                "load must declare either worker fields (runtime, entrypoint) or rust_dynamic fields (library, symbol), not both",
188            )),
189            (false, false) => Err(de::Error::custom(
190                "load must declare either worker fields (runtime, entrypoint) or rust_dynamic fields (library, symbol)",
191            )),
192        }
193    }
194}
195
196impl Serialize for DynamicPluginManifestLoad {
197    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
198    where
199        S: Serializer,
200    {
201        let raw = match self {
202            Self::Worker(load) => RawDynamicPluginManifestLoad {
203                runtime: load.runtime,
204                entrypoint: load.entrypoint.clone(),
205                library: None,
206                symbol: None,
207            },
208            Self::RustDynamic(load) => RawDynamicPluginManifestLoad {
209                runtime: None,
210                entrypoint: None,
211                library: load.library.clone(),
212                symbol: load.symbol.clone(),
213            },
214        };
215        raw.serialize(serializer)
216    }
217}
218
219/// Source block for authored manifests.
220#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
222pub struct DynamicPluginManifestSource {
223    /// Author-facing manifest root or package root.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub manifest_root: Option<String>,
226    /// Author-facing artifact hint.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub artifact: Option<String>,
229}
230
231/// Integrity/authenticity evidence block for authored manifests.
232#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
234pub struct DynamicPluginManifestIntegrity {
235    /// Expected artifact digest.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub sha256: Option<String>,
238    /// Optional signature reference.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub signature: Option<String>,
241}
242
243impl DynamicPluginManifest {
244    /// Parses a `relay-plugin.toml` manifest from TOML text.
245    pub fn parse_toml(toml_source: &str) -> Result<Self> {
246        let manifest = toml::from_str::<Self>(toml_source).map_err(|err| {
247            PluginError::InvalidConfig(format!("invalid relay-plugin.toml: {err}"))
248        })?;
249        manifest.validate()?;
250        Ok(manifest)
251    }
252
253    /// Loads and validates a `relay-plugin.toml` manifest from a file path or directory.
254    pub fn load_from_path(path: impl AsRef<Path>) -> Result<(Self, String)> {
255        let path = path.as_ref();
256        let manifest_path = if path.is_dir() {
257            path.join(DYNAMIC_PLUGIN_MANIFEST_FILENAME)
258        } else {
259            path.to_path_buf()
260        };
261
262        let display_ref = manifest_path.to_string_lossy().into_owned();
263        let exists = manifest_path.try_exists().map_err(|err| {
264            PluginError::Internal(format!("failed to inspect '{}': {err}", display_ref))
265        })?;
266        if !exists {
267            return Err(PluginError::NotFound(format!(
268                "dynamic plugin manifest '{}' does not exist",
269                display_ref
270            )));
271        }
272
273        let normalized_manifest_path = fs::canonicalize(&manifest_path).map_err(|err| {
274            PluginError::Internal(format!(
275                "failed to normalize dynamic plugin manifest '{}': {err}",
276                display_ref
277            ))
278        })?;
279        let manifest_ref = normalized_manifest_path.to_string_lossy().into_owned();
280
281        let contents = fs::read_to_string(&normalized_manifest_path).map_err(|err| {
282            PluginError::Internal(format!(
283                "failed to read dynamic plugin manifest '{}': {err}",
284                manifest_ref
285            ))
286        })?;
287        let manifest = Self::parse_toml(&contents)?;
288        Ok((manifest, manifest_ref))
289    }
290
291    /// Validates the authored manifest against the v1 contract.
292    pub fn validate(&self) -> Result<()> {
293        if self.manifest_version != 1 {
294            return Err(PluginError::InvalidConfig(format!(
295                "unsupported relay-plugin.toml manifest_version {}; expected 1",
296                self.manifest_version
297            )));
298        }
299        if self.plugin.id.trim().is_empty() {
300            return Err(PluginError::InvalidConfig(
301                "plugin.id must not be empty".into(),
302            ));
303        }
304        ensure_optional_string_non_empty(self.plugin.name.as_deref(), "plugin.name")?;
305        ensure_optional_string_non_empty(self.plugin.version.as_deref(), "plugin.version")?;
306        ensure_optional_string_non_empty(self.description.as_deref(), "description")?;
307        ensure_optional_string_non_empty(
308            self.source
309                .as_ref()
310                .and_then(|source| source.manifest_root.as_deref()),
311            "source.manifest_root",
312        )?;
313        ensure_optional_string_non_empty(
314            self.source
315                .as_ref()
316                .and_then(|source| source.artifact.as_deref()),
317            "source.artifact",
318        )?;
319        ensure_optional_string_non_empty(
320            self.integrity
321                .as_ref()
322                .and_then(|integrity| integrity.sha256.as_deref()),
323            "integrity.sha256",
324        )?;
325        ensure_optional_string_non_empty(
326            self.integrity
327                .as_ref()
328                .and_then(|integrity| integrity.signature.as_deref()),
329            "integrity.signature",
330        )?;
331
332        required_trimmed_string(self.compat.relay.as_deref(), "compat.relay")?;
333        if self.capabilities.items.is_empty() {
334            return Err(PluginError::InvalidConfig(
335                "capabilities.items must declare at least one capability".into(),
336            ));
337        }
338        reject_duplicate_capabilities(&self.capabilities.items)?;
339        validate_config_schema_contract(&self.capabilities.items, self.config_schema.as_ref())?;
340
341        if self.defaults.enabled {
342            return Err(PluginError::InvalidConfig(
343                "defaults.enabled=true is not supported for dynamic plugins; plugins are added disabled and require explicit enablement".into(),
344            ));
345        }
346
347        validate_capability_shape(self.plugin.kind, &self.capabilities.items)?;
348        validate_load_shape(self.plugin.kind, &self.load)?;
349        validate_compat_shape(self.plugin.kind, &self.compat)?;
350        Ok(())
351    }
352
353    /// Resolves the declared configuration schema path without accessing it.
354    ///
355    /// Relative schema paths are resolved from the parent directory of the
356    /// canonical `relay-plugin.toml` path. Absolute local paths are returned
357    /// unchanged.
358    pub fn resolve_config_schema_path(
359        &self,
360        canonical_manifest_path: impl AsRef<Path>,
361    ) -> Result<Option<PathBuf>> {
362        let Some(config_schema) = &self.config_schema else {
363            return Ok(None);
364        };
365
366        let schema_path = Path::new(validate_config_schema_path(config_schema)?);
367        if schema_path.is_absolute() {
368            return Ok(Some(schema_path.to_path_buf()));
369        }
370
371        let manifest_path = canonical_manifest_path.as_ref();
372        let manifest_parent = manifest_path.parent().ok_or_else(|| {
373            PluginError::InvalidConfig(format!(
374                "dynamic plugin manifest path '{}' has no parent directory",
375                manifest_path.display()
376            ))
377        })?;
378        Ok(Some(manifest_parent.join(schema_path)))
379    }
380
381    /// Converts the authored manifest into a durable control-plane record.
382    pub fn into_record(self, manifest_ref: Option<String>) -> Result<DynamicPluginRecord> {
383        self.validate()?;
384        let validation = self.validation_status();
385        let plugin = self.plugin;
386        let compat = self.compat;
387        let load = self.load;
388
389        Ok(DynamicPluginRecord {
390            metadata: DynamicPluginMetadata {
391                id: plugin.id.trim().to_owned(),
392                name: plugin.name,
393                version: plugin.version,
394                kind: plugin.kind,
395                generation: 0,
396                created_at: None,
397                updated_at: None,
398            },
399            source: DynamicPluginSource {
400                manifest_ref,
401                artifact_ref: self
402                    .source
403                    .as_ref()
404                    .and_then(|source| source.artifact.clone()),
405                environment_ref: None,
406                artifact_digest: self
407                    .integrity
408                    .as_ref()
409                    .and_then(|integrity| integrity.sha256.clone()),
410            },
411            spec: DynamicPluginSpec {
412                present: true,
413                enabled: false,
414                config_ref: None,
415            },
416            compatibility: match plugin.kind {
417                DynamicPluginKind::RustDynamic => {
418                    DynamicPluginCompatibility::RustDynamic(DynamicPluginRustCompatibility {
419                        relay: compat
420                            .relay
421                            .expect("validated manifest must carry compat.relay")
422                            .trim()
423                            .to_owned(),
424                        native_api: compat
425                            .native_api
426                            .expect("validated rust_dynamic manifest must carry compat.native_api")
427                            .trim()
428                            .to_owned(),
429                    })
430                }
431                DynamicPluginKind::Worker => {
432                    DynamicPluginCompatibility::Worker(DynamicPluginWorkerCompatibility {
433                        relay: compat
434                            .relay
435                            .expect("validated manifest must carry compat.relay")
436                            .trim()
437                            .to_owned(),
438                        worker_protocol: compat
439                            .worker_protocol
440                            .expect("validated worker manifest must carry compat.worker_protocol")
441                            .trim()
442                            .to_owned(),
443                    })
444                }
445            },
446            load: load.into_record_load_contract(),
447            status: DynamicPluginStatus {
448                validation,
449                ..DynamicPluginStatus::default()
450            },
451        })
452    }
453
454    /// Produces the initial validation status for a successfully validated manifest.
455    pub fn validation_status(&self) -> DynamicPluginValidationStatus {
456        DynamicPluginValidationStatus {
457            manifest: DynamicPluginCheckState::Valid,
458            compatibility: DynamicPluginCheckState::Unknown,
459            integrity: DynamicPluginCheckState::Unknown,
460            environment: DynamicPluginCheckState::Unknown,
461            authenticity: DynamicPluginCheckState::Unknown,
462            policy_satisfied: DynamicPluginCheckState::Unknown,
463            checked_at: Some(current_timestamp()),
464            message: Some("manifest validated".into()),
465        }
466    }
467}
468
469fn required_string<'a>(value: Option<&'a str>, field: &str) -> Result<&'a str> {
470    value.ok_or_else(|| PluginError::InvalidConfig(format!("{field} is required")))
471}
472
473fn required_trimmed_string<'a>(value: Option<&'a str>, field: &str) -> Result<&'a str> {
474    let value = required_string(value, field)?;
475    if value.trim().is_empty() {
476        return Err(PluginError::InvalidConfig(format!(
477            "{field} must not be empty"
478        )));
479    }
480    // Validate trimmed content while returning the original slice so each caller
481    // can decide whether preserving or normalizing whitespace is correct.
482    Ok(value)
483}
484
485fn ensure_optional_string_non_empty(value: Option<&str>, field: &str) -> Result<()> {
486    if value.is_some_and(|value| value.trim().is_empty()) {
487        return Err(PluginError::InvalidConfig(format!(
488            "{field} must not be empty when provided"
489        )));
490    }
491    Ok(())
492}
493
494fn reject_duplicate_capabilities(capabilities: &[DynamicPluginCapability]) -> Result<()> {
495    let mut seen = HashSet::with_capacity(capabilities.len());
496    for capability in capabilities {
497        if !seen.insert(*capability) {
498            return Err(PluginError::InvalidConfig(format!(
499                "capabilities.items contains duplicate capability '{capability:?}'"
500            )));
501        }
502    }
503    Ok(())
504}
505
506fn validate_config_schema_contract(
507    capabilities: &[DynamicPluginCapability],
508    config_schema: Option<&DynamicPluginManifestConfigSchema>,
509) -> Result<()> {
510    let has_capability = capabilities.contains(&DynamicPluginCapability::ConfigSchema);
511    match (has_capability, config_schema) {
512        (true, None) => {
513            return Err(PluginError::InvalidConfig(
514                "capabilities.items containing config_schema requires a [config_schema] section"
515                    .into(),
516            ));
517        }
518        (false, Some(_)) => {
519            return Err(PluginError::InvalidConfig(
520                "[config_schema] requires capabilities.items containing config_schema".into(),
521            ));
522        }
523        (false, None) => return Ok(()),
524        (true, Some(config_schema)) => {
525            validate_config_schema_path(config_schema)?;
526        }
527    }
528    Ok(())
529}
530
531fn validate_config_schema_path(config_schema: &DynamicPluginManifestConfigSchema) -> Result<&str> {
532    let path = required_trimmed_string(Some(&config_schema.path), "config_schema.path")?;
533    if has_uri_scheme(path) || is_unc_path(path) {
534        return Err(PluginError::InvalidConfig(
535            "config_schema.path must be a local filesystem path, not a URI or network share".into(),
536        ));
537    }
538    Ok(path)
539}
540
541fn is_unc_path(value: &str) -> bool {
542    // A leading `//` can identify a local path on POSIX, but manifests are portable and the same
543    // spelling is a UNC network share on Windows, so reject it consistently on every platform.
544    value.starts_with(r"\\") || value.starts_with("//")
545}
546
547fn has_uri_scheme(value: &str) -> bool {
548    if value
549        .get(..5)
550        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("file:"))
551    {
552        return true;
553    }
554    let Some((scheme, _)) = value.split_once("://") else {
555        return false;
556    };
557    let mut chars = scheme.chars();
558    chars
559        .next()
560        .is_some_and(|first| first.is_ascii_alphabetic())
561        && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
562}
563
564fn validate_capability_shape(
565    kind: DynamicPluginKind,
566    capabilities: &[DynamicPluginCapability],
567) -> Result<()> {
568    let has_native = capabilities.contains(&DynamicPluginCapability::PluginNative);
569    let has_worker = capabilities.contains(&DynamicPluginCapability::PluginWorker);
570    match kind {
571        DynamicPluginKind::RustDynamic => {
572            if !has_native {
573                return Err(PluginError::InvalidConfig(
574                    "rust_dynamic plugins must declare capabilities.items containing plugin_native"
575                        .into(),
576                ));
577            }
578            if has_worker {
579                return Err(PluginError::InvalidConfig(
580                    "rust_dynamic plugins must not declare plugin_worker".into(),
581                ));
582            }
583        }
584        DynamicPluginKind::Worker => {
585            if !has_worker {
586                return Err(PluginError::InvalidConfig(
587                    "worker plugins must declare capabilities.items containing plugin_worker"
588                        .into(),
589                ));
590            }
591            if has_native {
592                return Err(PluginError::InvalidConfig(
593                    "worker plugins must not declare plugin_native".into(),
594                ));
595            }
596        }
597    }
598    Ok(())
599}
600
601fn validate_load_shape(kind: DynamicPluginKind, load: &DynamicPluginManifestLoad) -> Result<()> {
602    match (kind, load) {
603        (DynamicPluginKind::RustDynamic, DynamicPluginManifestLoad::Worker(_)) => {
604            return Err(PluginError::InvalidConfig(
605                "rust_dynamic plugins must not declare load.runtime or load.entrypoint".into(),
606            ));
607        }
608        (DynamicPluginKind::Worker, DynamicPluginManifestLoad::RustDynamic(_)) => {
609            return Err(PluginError::InvalidConfig(
610                "worker plugins must not declare load.library or load.symbol".into(),
611            ));
612        }
613        (DynamicPluginKind::RustDynamic, DynamicPluginManifestLoad::RustDynamic(load)) => {
614            required_trimmed_string(load.library.as_deref(), "load.library")?;
615            required_trimmed_string(load.symbol.as_deref(), "load.symbol")?;
616        }
617        (DynamicPluginKind::Worker, DynamicPluginManifestLoad::Worker(load)) => {
618            required_trimmed_string(load.entrypoint.as_deref(), "load.entrypoint")?;
619            match load.runtime {
620                Some(WorkerRuntime::Python | WorkerRuntime::Rust | WorkerRuntime::Command) => {}
621                None => {
622                    return Err(PluginError::InvalidConfig(
623                        "worker plugins must declare load.runtime".into(),
624                    ));
625                }
626            }
627        }
628    }
629    Ok(())
630}
631
632impl DynamicPluginManifestLoad {
633    fn into_record_load_contract(self) -> DynamicPluginLoadContract {
634        match self {
635            Self::Worker(load) => {
636                DynamicPluginLoadContract::Worker(DynamicPluginWorkerLoadContract {
637                    runtime: load
638                        .runtime
639                        .expect("validated worker manifest must carry load.runtime"),
640                    entrypoint: load
641                        .entrypoint
642                        .expect("validated worker manifest must carry load.entrypoint")
643                        .trim()
644                        .to_owned(),
645                })
646            }
647            Self::RustDynamic(load) => {
648                DynamicPluginLoadContract::RustDynamic(DynamicPluginRustLoadContract {
649                    library: load
650                        .library
651                        .expect("validated rust_dynamic manifest must carry load.library")
652                        .trim()
653                        .to_owned(),
654                    symbol: load
655                        .symbol
656                        .expect("validated rust_dynamic manifest must carry load.symbol")
657                        .trim()
658                        .to_owned(),
659                })
660            }
661        }
662    }
663}
664
665fn validate_compat_shape(
666    kind: DynamicPluginKind,
667    compat: &DynamicPluginManifestCompat,
668) -> Result<()> {
669    match kind {
670        DynamicPluginKind::RustDynamic => {
671            required_trimmed_string(compat.native_api.as_deref(), "compat.native_api")?;
672            if compat.worker_protocol.is_some() {
673                return Err(PluginError::InvalidConfig(
674                    "rust_dynamic plugins must not declare compat.worker_protocol".into(),
675                ));
676            }
677        }
678        DynamicPluginKind::Worker => {
679            let worker_protocol = required_trimmed_string(
680                compat.worker_protocol.as_deref(),
681                "compat.worker_protocol",
682            )?;
683            if worker_protocol.trim() != SUPPORTED_WORKER_PROTOCOL {
684                return Err(PluginError::InvalidConfig(format!(
685                    "worker plugins must declare compat.worker_protocol = \"{SUPPORTED_WORKER_PROTOCOL}\""
686                )));
687            }
688            if compat.native_api.is_some() {
689                return Err(PluginError::InvalidConfig(
690                    "worker plugins must not declare compat.native_api".into(),
691                ));
692            }
693        }
694    }
695    Ok(())
696}