Skip to main content

player_plugin/
plugin_reference.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use thiserror::Error;
3
4use player_plugin_abi::{VESPER_MAX_CAPABILITY_INSTANCE_ID_BYTES, VESPER_MAX_PLUGIN_ID_BYTES};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum PluginTransport {
9    Native,
10    Wasm,
11}
12
13#[derive(Debug, Error, Clone, PartialEq, Eq)]
14pub enum PluginReferenceError {
15    #[error("plugin_id must be a valid reverse-DNS identity")]
16    InvalidPluginId,
17    #[error("capability_instance_id must be a valid reverse-DNS identity")]
18    InvalidCapabilityInstanceId,
19}
20
21/// Explicit selection of one plugin and transport.
22///
23/// Omitting `capability_instance_id` asks the registry to select the only
24/// implementation of the requested interface. Zero or multiple matches are
25/// errors; selection never falls back to another transport.
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct PluginReference {
29    plugin_id: String,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    capability_instance_id: Option<String>,
32    transport: PluginTransport,
33}
34
35impl PluginReference {
36    pub fn new(
37        plugin_id: impl Into<String>,
38        capability_instance_id: Option<String>,
39        transport: PluginTransport,
40    ) -> Result<Self, PluginReferenceError> {
41        let plugin_id = plugin_id.into();
42        if !is_reverse_dns(&plugin_id, VESPER_MAX_PLUGIN_ID_BYTES) {
43            return Err(PluginReferenceError::InvalidPluginId);
44        }
45        if let Some(instance_id) = capability_instance_id.as_deref()
46            && !is_reverse_dns(instance_id, VESPER_MAX_CAPABILITY_INSTANCE_ID_BYTES)
47        {
48            return Err(PluginReferenceError::InvalidCapabilityInstanceId);
49        }
50        Ok(Self {
51            plugin_id,
52            capability_instance_id,
53            transport,
54        })
55    }
56
57    pub fn plugin_id(&self) -> &str {
58        &self.plugin_id
59    }
60
61    pub fn capability_instance_id(&self) -> Option<&str> {
62        self.capability_instance_id.as_deref()
63    }
64
65    pub const fn transport(&self) -> PluginTransport {
66        self.transport
67    }
68}
69
70#[derive(Deserialize)]
71#[serde(rename_all = "camelCase")]
72struct PluginReferenceWire {
73    plugin_id: String,
74    #[serde(default)]
75    capability_instance_id: Option<String>,
76    transport: PluginTransport,
77}
78
79impl<'de> Deserialize<'de> for PluginReference {
80    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
81    where
82        D: Deserializer<'de>,
83    {
84        let wire = PluginReferenceWire::deserialize(deserializer)?;
85        Self::new(wire.plugin_id, wire.capability_instance_id, wire.transport)
86            .map_err(serde::de::Error::custom)
87    }
88}
89
90pub(crate) fn is_reverse_dns(value: &str, max_bytes: usize) -> bool {
91    if value.is_empty() || value.len() > max_bytes || !value.is_ascii() {
92        return false;
93    }
94    let mut segments = value.split('.');
95    let Some(first) = segments.next() else {
96        return false;
97    };
98    let Some(second) = segments.next() else {
99        return false;
100    };
101    valid_segment(first) && valid_segment(second) && segments.all(valid_segment)
102}
103
104fn valid_segment(segment: &str) -> bool {
105    let bytes = segment.as_bytes();
106    matches!(bytes.first(), Some(b'a'..=b'z'))
107        && matches!(bytes.last(), Some(b'a'..=b'z' | b'0'..=b'9'))
108        && bytes
109            .iter()
110            .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-'))
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn reference_requires_explicit_transport_and_preserves_identity() {
119        let reference = PluginReference::new(
120            "dev.vesper.example-plugin",
121            Some("dev.vesper.example-plugin.primary".to_owned()),
122            PluginTransport::Wasm,
123        )
124        .expect("valid reference");
125        let encoded = serde_json::to_value(&reference).expect("serialize reference");
126        assert_eq!(encoded["pluginId"], "dev.vesper.example-plugin");
127        assert_eq!(encoded["transport"], "wasm");
128        assert_eq!(
129            serde_json::from_value::<PluginReference>(encoded).expect("deserialize reference"),
130            reference
131        );
132    }
133
134    #[test]
135    fn reference_rejects_lossy_or_ambiguous_identity_forms() {
136        for invalid in [
137            "Vesper.Plugin",
138            "vesper",
139            "dev..plugin",
140            "dev.plugin_1",
141            "dev.plugin/../other",
142            "开发.插件",
143        ] {
144            assert_eq!(
145                PluginReference::new(invalid, None, PluginTransport::Native),
146                Err(PluginReferenceError::InvalidPluginId),
147                "invalid identity {invalid}"
148            );
149        }
150    }
151
152    #[test]
153    fn deserialization_cannot_bypass_validation() {
154        let error = serde_json::from_str::<PluginReference>(
155            r#"{"pluginId":"invalid","transport":"native"}"#,
156        )
157        .expect_err("invalid reference");
158        assert!(error.to_string().contains("reverse-DNS"));
159    }
160}