1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27pub struct DynamicPluginManifest {
28 pub manifest_version: u32,
30 pub plugin: DynamicPluginManifestPlugin,
32 pub compat: DynamicPluginManifestCompat,
34 pub defaults: DynamicPluginManifestDefaults,
36 pub capabilities: DynamicPluginManifestCapabilities,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub config_schema: Option<DynamicPluginManifestConfigSchema>,
41 #[cfg_attr(feature = "schema", schemars(with = "DynamicPluginManifestLoadSchema"))]
43 pub load: DynamicPluginManifestLoad,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub source: Option<DynamicPluginManifestSource>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub integrity: Option<DynamicPluginManifestIntegrity>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub description: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub struct DynamicPluginManifestPlugin {
59 pub id: DynamicPluginId,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub name: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub version: Option<String>,
67 pub kind: DynamicPluginKind,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74pub struct DynamicPluginManifestCompat {
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub relay: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub native_api: Option<String>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub worker_protocol: Option<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89pub struct DynamicPluginManifestDefaults {
90 #[serde(default)]
92 pub enabled: bool,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
98pub struct DynamicPluginManifestCapabilities {
99 pub items: Vec<DynamicPluginCapability>,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct DynamicPluginManifestConfigSchema {
107 pub path: String,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum DynamicPluginManifestLoad {
114 Worker(DynamicPluginManifestWorkerLoad),
116 RustDynamic(DynamicPluginManifestRustDynamicLoad),
118}
119
120#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct DynamicPluginManifestWorkerLoad {
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub runtime: Option<WorkerRuntime>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub entrypoint: Option<String>,
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
134#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
135pub struct DynamicPluginManifestRustDynamicLoad {
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub library: Option<String>,
139 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
222pub struct DynamicPluginManifestSource {
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub manifest_root: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub artifact: Option<String>,
229}
230
231#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
234pub struct DynamicPluginManifestIntegrity {
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub sha256: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub signature: Option<String>,
241}
242
243impl DynamicPluginManifest {
244 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 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 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 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 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 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 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 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}