player_plugin/
plugin_reference.rs1use 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#[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}