Skip to main content

cc_lb_plugin_wire/
augmented_metadata.rs

1extern crate alloc;
2
3use alloc::{
4    collections::{BTreeMap, BTreeSet},
5    string::{String, ToString},
6};
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10use crate::identity::PluginIdentity;
11
12/// Augmented metadata combining plugin identity with negotiated capabilities.
13///
14/// This is the single source of truth for negotiated plugin capabilities and versions.
15/// It encapsulates the result of a successful handshake and self-check.
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(deny_unknown_fields)]
18pub struct AugmentedMetadata {
19    /// The plugin's identity metadata (magic, abi_envelope, name, version).
20    pub identity: PluginIdentity,
21
22    /// Negotiated wire function versions: function_name -> negotiated_version.
23    /// Ordered via BTreeMap for deterministic serialization.
24    pub negotiated_functions: BTreeMap<String, u32>,
25
26    /// Negotiated capabilities offered by the plugin.
27    /// Ordered via BTreeSet for deterministic serialization.
28    pub negotiated_capabilities: BTreeSet<String>,
29
30    /// Unix timestamp (seconds) when handshake was completed.
31    pub handshake_completed_at: i64,
32
33    /// Whether the self-check passed successfully.
34    pub self_check_passed: bool,
35
36    /// Unix timestamp (seconds) when self-check was completed.
37    pub self_check_completed_at: i64,
38
39    /// Unix timestamp (seconds) when this metadata expires and re-handshake is needed.
40    pub expires_at: i64,
41}
42
43/// Record in the plugin registry, tracking a deployed plugin instance.
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct PluginRegistryRecord {
47    /// Unique plugin instance identifier (UUID or similar).
48    pub plugin_id: String,
49
50    /// Augmented metadata for this plugin instance.
51    pub augmented_metadata: AugmentedMetadata,
52
53    /// Unix timestamp (seconds) when the plugin was registered.
54    pub registered_at: i64,
55}
56
57/// Error type for augmented metadata operations.
58#[derive(Debug, Clone, Error, PartialEq, Eq)]
59pub enum AugmentedMetadataError {
60    #[error("identity validation failed: {0}")]
61    IdentityValidationFailed(String),
62
63    #[error(
64        "augmented metadata serialization exceeds max size of {} bytes",
65        crate::limits::AUGMENTED_METADATA_MAX_BYTES
66    )]
67    SizeLimitExceeded,
68
69    #[error("invalid timestamp: {0}")]
70    InvalidTimestamp(String),
71
72    #[error("expires_at must be after handshake_completed_at")]
73    ExpirationBeforeHandshake,
74
75    #[error("no negotiated functions")]
76    NoNegotiatedFunctions,
77
78    #[error("serialization failed: {0}")]
79    SerializationFailed(String),
80}
81
82impl AugmentedMetadata {
83    /// Validate this augmented metadata.
84    pub fn validate(&self) -> Result<(), AugmentedMetadataError> {
85        // Validate identity.
86        self.identity
87            .validate()
88            .map_err(|e| AugmentedMetadataError::IdentityValidationFailed(e.to_string()))?;
89
90        // Validate timestamps.
91        if self.handshake_completed_at <= 0 {
92            return Err(AugmentedMetadataError::InvalidTimestamp(
93                "handshake_completed_at must be positive".to_string(),
94            ));
95        }
96
97        if self.self_check_completed_at <= 0 {
98            return Err(AugmentedMetadataError::InvalidTimestamp(
99                "self_check_completed_at must be positive".to_string(),
100            ));
101        }
102
103        if self.expires_at <= 0 {
104            return Err(AugmentedMetadataError::InvalidTimestamp(
105                "expires_at must be positive".to_string(),
106            ));
107        }
108
109        if self.expires_at <= self.handshake_completed_at {
110            return Err(AugmentedMetadataError::ExpirationBeforeHandshake);
111        }
112
113        // Validate negotiated functions.
114        if self.negotiated_functions.is_empty() {
115            return Err(AugmentedMetadataError::NoNegotiatedFunctions);
116        }
117
118        // Validate size limit.
119        self.check_size_limit()?;
120
121        Ok(())
122    }
123
124    /// Check that serialized size does not exceed AUGMENTED_METADATA_MAX_BYTES.
125    pub fn check_size_limit(&self) -> Result<(), AugmentedMetadataError> {
126        let serialized = serde_json::to_vec(self)
127            .map_err(|e| AugmentedMetadataError::SerializationFailed(e.to_string()))?;
128
129        if serialized.len() > crate::limits::AUGMENTED_METADATA_MAX_BYTES {
130            return Err(AugmentedMetadataError::SizeLimitExceeded);
131        }
132
133        Ok(())
134    }
135
136    /// Create AugmentedMetadata from handshake and self-check responses.
137    ///
138    /// Combines:
139    /// - Plugin identity from handshake
140    /// - Negotiated functions and capabilities from handshake
141    /// - Self-check result
142    ///
143    /// # Arguments
144    /// * `identity` - The plugin's identity metadata
145    /// * `negotiated_functions` - BTreeMap of function_name -> negotiated_version
146    /// * `negotiated_capabilities` - BTreeSet of negotiated capability names
147    /// * `handshake_completed_at` - Timestamp when handshake completed
148    /// * `self_check_passed` - Whether self-check passed
149    /// * `self_check_completed_at` - Timestamp when self-check completed
150    /// * `ttl_seconds` - TTL for this metadata in seconds
151    pub fn from_handshake_and_self_check(
152        identity: PluginIdentity,
153        negotiated_functions: BTreeMap<String, u32>,
154        negotiated_capabilities: BTreeSet<String>,
155        handshake_completed_at: i64,
156        self_check_passed: bool,
157        self_check_completed_at: i64,
158        ttl_seconds: u64,
159    ) -> Result<Self, AugmentedMetadataError> {
160        let expires_at = handshake_completed_at + ttl_seconds as i64;
161
162        let metadata = AugmentedMetadata {
163            identity,
164            negotiated_functions,
165            negotiated_capabilities,
166            handshake_completed_at,
167            self_check_passed,
168            self_check_completed_at,
169            expires_at,
170        };
171
172        metadata.validate()?;
173        Ok(metadata)
174    }
175
176    /// Check if this metadata has expired.
177    pub fn is_expired(&self, now_timestamp: i64) -> bool {
178        now_timestamp >= self.expires_at
179    }
180
181    /// Get the TTL remaining in seconds.
182    pub fn ttl_seconds(&self, now_timestamp: i64) -> i64 {
183        (self.expires_at - now_timestamp).max(0)
184    }
185}
186
187impl PluginRegistryRecord {
188    /// Validate this registry record.
189    pub fn validate(&self) -> Result<(), AugmentedMetadataError> {
190        if self.plugin_id.is_empty() {
191            return Err(AugmentedMetadataError::InvalidTimestamp(
192                "plugin_id must not be empty".to_string(),
193            ));
194        }
195
196        if self.registered_at <= 0 {
197            return Err(AugmentedMetadataError::InvalidTimestamp(
198                "registered_at must be positive".to_string(),
199            ));
200        }
201
202        self.augmented_metadata.validate()?;
203
204        Ok(())
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn make_test_identity() -> PluginIdentity {
213        PluginIdentity {
214            magic: crate::identity::CC_LB_PLUGIN_MAGIC,
215            abi_envelope: 1,
216            plugin_name: "test-plugin".to_string(),
217            plugin_version: "1.0.0".to_string(),
218        }
219    }
220
221    #[test]
222    fn test_augmented_metadata_valid() {
223        let mut functions = BTreeMap::new();
224        functions.insert("route".to_string(), 1);
225
226        let mut capabilities = BTreeSet::new();
227        capabilities.insert("async".to_string());
228
229        let metadata = AugmentedMetadata {
230            identity: make_test_identity(),
231            negotiated_functions: functions,
232            negotiated_capabilities: capabilities,
233            handshake_completed_at: 1000,
234            self_check_passed: true,
235            self_check_completed_at: 1100,
236            expires_at: 2000,
237        };
238
239        assert!(metadata.validate().is_ok());
240    }
241
242    #[test]
243    fn test_augmented_metadata_no_functions() {
244        let metadata = AugmentedMetadata {
245            identity: make_test_identity(),
246            negotiated_functions: BTreeMap::new(),
247            negotiated_capabilities: BTreeSet::new(),
248            handshake_completed_at: 1000,
249            self_check_passed: true,
250            self_check_completed_at: 1100,
251            expires_at: 2000,
252        };
253
254        assert_eq!(
255            metadata.validate(),
256            Err(AugmentedMetadataError::NoNegotiatedFunctions)
257        );
258    }
259
260    #[test]
261    fn test_augmented_metadata_invalid_handshake_timestamp() {
262        let mut functions = BTreeMap::new();
263        functions.insert("route".to_string(), 1);
264
265        let metadata = AugmentedMetadata {
266            identity: make_test_identity(),
267            negotiated_functions: functions,
268            negotiated_capabilities: BTreeSet::new(),
269            handshake_completed_at: 0,
270            self_check_passed: true,
271            self_check_completed_at: 1100,
272            expires_at: 2000,
273        };
274
275        assert!(metadata.validate().is_err());
276    }
277
278    #[test]
279    fn test_augmented_metadata_expiration_before_handshake() {
280        let mut functions = BTreeMap::new();
281        functions.insert("route".to_string(), 1);
282
283        let metadata = AugmentedMetadata {
284            identity: make_test_identity(),
285            negotiated_functions: functions,
286            negotiated_capabilities: BTreeSet::new(),
287            handshake_completed_at: 2000,
288            self_check_passed: true,
289            self_check_completed_at: 2100,
290            expires_at: 1000,
291        };
292
293        assert_eq!(
294            metadata.validate(),
295            Err(AugmentedMetadataError::ExpirationBeforeHandshake)
296        );
297    }
298
299    #[test]
300    fn test_augmented_metadata_serde() {
301        let mut functions = BTreeMap::new();
302        functions.insert("route".to_string(), 1);
303        functions.insert("observe".to_string(), 2);
304
305        let mut capabilities = BTreeSet::new();
306        capabilities.insert("async".to_string());
307        capabilities.insert("streaming".to_string());
308
309        let metadata = AugmentedMetadata {
310            identity: make_test_identity(),
311            negotiated_functions: functions,
312            negotiated_capabilities: capabilities,
313            handshake_completed_at: 1000,
314            self_check_passed: true,
315            self_check_completed_at: 1100,
316            expires_at: 2000,
317        };
318
319        let json = serde_json::to_string(&metadata).unwrap();
320        let deserialized: AugmentedMetadata = serde_json::from_str(&json).unwrap();
321        assert_eq!(metadata, deserialized);
322    }
323
324    #[test]
325    fn test_augmented_metadata_deny_unknown_fields() {
326        let json = r#"{"identity":{"magic":[204,27,112,16,0,1,0,0],"abi_envelope":1,"plugin_name":"test","plugin_version":"1.0"},"negotiated_functions":{"route":1},"negotiated_capabilities":[],"handshake_completed_at":1000,"self_check_passed":true,"self_check_completed_at":1100,"expires_at":2000,"unknown":"field"}"#;
327        let result: Result<AugmentedMetadata, _> = serde_json::from_str(json);
328        assert!(result.is_err());
329    }
330
331    #[test]
332    fn test_augmented_metadata_size_limit() {
333        let mut functions = BTreeMap::new();
334        functions.insert("route".to_string(), 1);
335
336        let mut capabilities = BTreeSet::new();
337        // Add a huge capability name to exceed size limit
338        capabilities.insert("x".repeat(crate::limits::AUGMENTED_METADATA_MAX_BYTES + 1));
339
340        let metadata = AugmentedMetadata {
341            identity: make_test_identity(),
342            negotiated_functions: functions,
343            negotiated_capabilities: capabilities,
344            handshake_completed_at: 1000,
345            self_check_passed: true,
346            self_check_completed_at: 1100,
347            expires_at: 2000,
348        };
349
350        assert_eq!(
351            metadata.validate(),
352            Err(AugmentedMetadataError::SizeLimitExceeded)
353        );
354    }
355
356    #[test]
357    fn test_from_handshake_and_self_check() {
358        let mut functions = BTreeMap::new();
359        functions.insert("route".to_string(), 1);
360
361        let mut capabilities = BTreeSet::new();
362        capabilities.insert("async".to_string());
363
364        let metadata = AugmentedMetadata::from_handshake_and_self_check(
365            make_test_identity(),
366            functions,
367            capabilities,
368            1000,
369            true,
370            1100,
371            3600,
372        );
373
374        assert!(metadata.is_ok());
375        let m = metadata.unwrap();
376        assert_eq!(m.handshake_completed_at, 1000);
377        assert!(m.self_check_passed);
378        assert_eq!(m.expires_at, 1000 + 3600);
379    }
380
381    #[test]
382    fn test_from_handshake_and_self_check_validates() {
383        let mut functions = BTreeMap::new();
384        functions.insert("route".to_string(), 1);
385
386        // Try with invalid timestamp
387        let result = AugmentedMetadata::from_handshake_and_self_check(
388            make_test_identity(),
389            functions,
390            BTreeSet::new(),
391            0,
392            true,
393            1100,
394            3600,
395        );
396
397        assert!(result.is_err());
398    }
399
400    #[test]
401    fn test_is_expired() {
402        let mut functions = BTreeMap::new();
403        functions.insert("route".to_string(), 1);
404
405        let metadata = AugmentedMetadata {
406            identity: make_test_identity(),
407            negotiated_functions: functions,
408            negotiated_capabilities: BTreeSet::new(),
409            handshake_completed_at: 1000,
410            self_check_passed: true,
411            self_check_completed_at: 1100,
412            expires_at: 2000,
413        };
414
415        assert!(!metadata.is_expired(1999));
416        assert!(metadata.is_expired(2000));
417        assert!(metadata.is_expired(2001));
418    }
419
420    #[test]
421    fn test_ttl_seconds() {
422        let mut functions = BTreeMap::new();
423        functions.insert("route".to_string(), 1);
424
425        let metadata = AugmentedMetadata {
426            identity: make_test_identity(),
427            negotiated_functions: functions,
428            negotiated_capabilities: BTreeSet::new(),
429            handshake_completed_at: 1000,
430            self_check_passed: true,
431            self_check_completed_at: 1100,
432            expires_at: 2000,
433        };
434
435        assert_eq!(metadata.ttl_seconds(1500), 500);
436        assert_eq!(metadata.ttl_seconds(2000), 0);
437        assert_eq!(metadata.ttl_seconds(2100), 0);
438    }
439
440    #[test]
441    fn test_plugin_registry_record_valid() {
442        let mut functions = BTreeMap::new();
443        functions.insert("route".to_string(), 1);
444
445        let metadata = AugmentedMetadata {
446            identity: make_test_identity(),
447            negotiated_functions: functions,
448            negotiated_capabilities: BTreeSet::new(),
449            handshake_completed_at: 1000,
450            self_check_passed: true,
451            self_check_completed_at: 1100,
452            expires_at: 2000,
453        };
454
455        let record = PluginRegistryRecord {
456            plugin_id: "plugin-uuid-123".to_string(),
457            augmented_metadata: metadata,
458            registered_at: 900,
459        };
460
461        assert!(record.validate().is_ok());
462    }
463
464    #[test]
465    fn test_plugin_registry_record_empty_id() {
466        let mut functions = BTreeMap::new();
467        functions.insert("route".to_string(), 1);
468
469        let metadata = AugmentedMetadata {
470            identity: make_test_identity(),
471            negotiated_functions: functions,
472            negotiated_capabilities: BTreeSet::new(),
473            handshake_completed_at: 1000,
474            self_check_passed: true,
475            self_check_completed_at: 1100,
476            expires_at: 2000,
477        };
478
479        let record = PluginRegistryRecord {
480            plugin_id: String::new(),
481            augmented_metadata: metadata,
482            registered_at: 900,
483        };
484
485        assert!(record.validate().is_err());
486    }
487
488    #[test]
489    fn test_plugin_registry_record_serde() {
490        let mut functions = BTreeMap::new();
491        functions.insert("route".to_string(), 1);
492
493        let metadata = AugmentedMetadata {
494            identity: make_test_identity(),
495            negotiated_functions: functions,
496            negotiated_capabilities: BTreeSet::new(),
497            handshake_completed_at: 1000,
498            self_check_passed: true,
499            self_check_completed_at: 1100,
500            expires_at: 2000,
501        };
502
503        let record = PluginRegistryRecord {
504            plugin_id: "plugin-123".to_string(),
505            augmented_metadata: metadata,
506            registered_at: 900,
507        };
508
509        let json = serde_json::to_string(&record).unwrap();
510        let deserialized: PluginRegistryRecord = serde_json::from_str(&json).unwrap();
511        assert_eq!(record, deserialized);
512    }
513
514    #[test]
515    fn test_plugin_registry_record_deny_unknown_fields() {
516        let json = r#"{"plugin_id":"test","augmented_metadata":{"identity":{"magic":[204,27,112,16,0,1,0,0],"abi_envelope":1,"plugin_name":"test","plugin_version":"1.0"},"negotiated_functions":{"route":1},"negotiated_capabilities":[],"handshake_completed_at":1000,"self_check_passed":true,"self_check_completed_at":1100,"expires_at":2000},"registered_at":900,"unknown":"field"}"#;
517        let result: Result<PluginRegistryRecord, _> = serde_json::from_str(json);
518        assert!(result.is_err());
519    }
520}