Skip to main content

cc_lb_plugin_wire/
identity.rs

1extern crate alloc;
2
3use alloc::string::String;
4use serde::{Deserialize, Serialize};
5use thiserror::Error;
6
7/// 8-byte magic number identifying cc-lb plugin wire format v1.
8pub const CC_LB_PLUGIN_MAGIC: [u8; 8] = [0xCC, 0x1B, 0x70, 0x10, 0x00, 0x01, 0x00, 0x00];
9
10/// Custom section name for plugin identity metadata in WASM modules.
11pub const CC_LB_PLUGIN_SECTION_NAME: &str = "cc_lb.plugin.v1";
12
13/// Identity metadata for a cc-lb plugin.
14///
15/// This struct is serialized into a WASM custom section and used to validate
16/// plugin compatibility and metadata before instantiation.
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct PluginIdentity {
20    /// 8-byte magic number that must match CC_LB_PLUGIN_MAGIC.
21    pub magic: [u8; 8],
22
23    /// ABI envelope version or compatibility identifier.
24    pub abi_envelope: u32,
25
26    /// Plugin name: lowercase alphanumeric, underscore, and hyphen; max 64 bytes.
27    pub plugin_name: String,
28
29    /// Plugin semantic version string; max 32 bytes.
30    pub plugin_version: String,
31}
32
33/// Error type for identity validation failures.
34#[derive(Debug, Clone, Error, PartialEq, Eq)]
35pub enum IdentityError {
36    #[error("magic number mismatch")]
37    MagicMismatch,
38
39    #[error("plugin name is empty")]
40    PluginNameEmpty,
41
42    #[error("plugin name does not match pattern ^[a-z][a-z0-9_-]*$ (max 64 bytes)")]
43    PluginNameInvalid,
44
45    #[error("plugin version is empty")]
46    PluginVersionEmpty,
47
48    #[error("plugin version exceeds max length of 32 bytes")]
49    PluginVersionTooLong,
50}
51
52impl PluginIdentity {
53    /// Validate this identity against required constraints.
54    ///
55    /// Checks:
56    /// - magic matches CC_LB_PLUGIN_MAGIC
57    /// - plugin_name matches ^[a-z][a-z0-9_-]*$ and is at most 64 bytes
58    /// - plugin_version is at most 32 bytes and not empty
59    pub fn validate(&self) -> Result<(), IdentityError> {
60        // Check magic.
61        if self.magic != CC_LB_PLUGIN_MAGIC {
62            return Err(IdentityError::MagicMismatch);
63        }
64
65        // Check plugin_name.
66        if self.plugin_name.is_empty() {
67            return Err(IdentityError::PluginNameEmpty);
68        }
69
70        let name_bytes = self.plugin_name.as_bytes();
71        if name_bytes.len() > 64 {
72            return Err(IdentityError::PluginNameInvalid);
73        }
74
75        let is_valid_name = name_bytes[0].is_ascii_lowercase()
76            && name_bytes
77                .iter()
78                .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-');
79
80        if !is_valid_name {
81            return Err(IdentityError::PluginNameInvalid);
82        }
83
84        // Check plugin_version.
85        if self.plugin_version.is_empty() {
86            return Err(IdentityError::PluginVersionEmpty);
87        }
88
89        if self.plugin_version.len() > 32 {
90            return Err(IdentityError::PluginVersionTooLong);
91        }
92
93        Ok(())
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use alloc::string::ToString;
101
102    #[test]
103    fn test_valid_identity() {
104        let identity = PluginIdentity {
105            magic: CC_LB_PLUGIN_MAGIC,
106            abi_envelope: 1,
107            plugin_name: "my-plugin".to_string(),
108            plugin_version: "1.0.0".to_string(),
109        };
110        assert!(identity.validate().is_ok());
111    }
112
113    #[test]
114    fn test_magic_mismatch() {
115        let identity = PluginIdentity {
116            magic: [0; 8],
117            abi_envelope: 1,
118            plugin_name: "my-plugin".to_string(),
119            plugin_version: "1.0.0".to_string(),
120        };
121        assert_eq!(identity.validate(), Err(IdentityError::MagicMismatch));
122    }
123
124    #[test]
125    fn test_plugin_name_empty() {
126        let identity = PluginIdentity {
127            magic: CC_LB_PLUGIN_MAGIC,
128            abi_envelope: 1,
129            plugin_name: String::new(),
130            plugin_version: "1.0.0".to_string(),
131        };
132        assert_eq!(identity.validate(), Err(IdentityError::PluginNameEmpty));
133    }
134
135    #[test]
136    fn test_plugin_name_starts_with_uppercase() {
137        let identity = PluginIdentity {
138            magic: CC_LB_PLUGIN_MAGIC,
139            abi_envelope: 1,
140            plugin_name: "MyPlugin".to_string(),
141            plugin_version: "1.0.0".to_string(),
142        };
143        assert_eq!(identity.validate(), Err(IdentityError::PluginNameInvalid));
144    }
145
146    #[test]
147    fn test_plugin_name_with_invalid_char() {
148        let identity = PluginIdentity {
149            magic: CC_LB_PLUGIN_MAGIC,
150            abi_envelope: 1,
151            plugin_name: "my.plugin".to_string(),
152            plugin_version: "1.0.0".to_string(),
153        };
154        assert_eq!(identity.validate(), Err(IdentityError::PluginNameInvalid));
155    }
156
157    #[test]
158    fn test_plugin_name_too_long() {
159        let identity = PluginIdentity {
160            magic: CC_LB_PLUGIN_MAGIC,
161            abi_envelope: 1,
162            plugin_name: "a".repeat(65),
163            plugin_version: "1.0.0".to_string(),
164        };
165        assert_eq!(identity.validate(), Err(IdentityError::PluginNameInvalid));
166    }
167
168    #[test]
169    fn test_plugin_name_max_length() {
170        let identity = PluginIdentity {
171            magic: CC_LB_PLUGIN_MAGIC,
172            abi_envelope: 1,
173            plugin_name: "a".repeat(64),
174            plugin_version: "1.0.0".to_string(),
175        };
176        assert!(identity.validate().is_ok());
177    }
178
179    #[test]
180    fn test_plugin_name_with_underscore_and_hyphen() {
181        let identity = PluginIdentity {
182            magic: CC_LB_PLUGIN_MAGIC,
183            abi_envelope: 1,
184            plugin_name: "my_plugin-v2".to_string(),
185            plugin_version: "1.0.0".to_string(),
186        };
187        assert!(identity.validate().is_ok());
188    }
189
190    #[test]
191    fn test_plugin_version_empty() {
192        let identity = PluginIdentity {
193            magic: CC_LB_PLUGIN_MAGIC,
194            abi_envelope: 1,
195            plugin_name: "my-plugin".to_string(),
196            plugin_version: String::new(),
197        };
198        assert_eq!(identity.validate(), Err(IdentityError::PluginVersionEmpty));
199    }
200
201    #[test]
202    fn test_plugin_version_too_long() {
203        let identity = PluginIdentity {
204            magic: CC_LB_PLUGIN_MAGIC,
205            abi_envelope: 1,
206            plugin_name: "my-plugin".to_string(),
207            plugin_version: "v".repeat(33),
208        };
209        assert_eq!(
210            identity.validate(),
211            Err(IdentityError::PluginVersionTooLong)
212        );
213    }
214
215    #[test]
216    fn test_plugin_version_max_length() {
217        let identity = PluginIdentity {
218            magic: CC_LB_PLUGIN_MAGIC,
219            abi_envelope: 1,
220            plugin_name: "my-plugin".to_string(),
221            plugin_version: "v".repeat(32),
222        };
223        assert!(identity.validate().is_ok());
224    }
225
226    #[test]
227    fn test_serde_serialize_deserialize() {
228        let identity = PluginIdentity {
229            magic: CC_LB_PLUGIN_MAGIC,
230            abi_envelope: 1,
231            plugin_name: "my-plugin".to_string(),
232            plugin_version: "1.0.0".to_string(),
233        };
234
235        let json = serde_json::to_string(&identity).unwrap();
236        let deserialized: PluginIdentity = serde_json::from_str(&json).unwrap();
237
238        assert_eq!(identity, deserialized);
239    }
240
241    #[test]
242    fn test_serde_deny_unknown_fields() {
243        let json = r#"{"magic":[204,27,112,16,0,1,0,0],"abi_envelope":1,"plugin_name":"my-plugin","plugin_version":"1.0.0","unknown_field":"value"}"#;
244        let result: Result<PluginIdentity, _> = serde_json::from_str(json);
245        assert!(result.is_err());
246    }
247}