Skip to main content

ghostlink_core/
protocol.rs

1//! Binary Protocol with CRC32 Checksums for Ghost-Link Discovery
2//!
3//! This module implements a fixed-width binary protocol using:
4//! - Fixed-size fields for zero-copy parsing
5//! - CRC32 checksums for frame integrity
6//! - Sequence numbers and versioning for ordering
7
8use crc32fast::Hasher;
9
10/// Ghost-Link EtherType (0x88B5)
11pub const GHOSTLINK_ETHERTYPE: u16 = 0x88B5;
12
13/// Protocol version
14pub const PROTOCOL_VERSION: u8 = 1;
15
16/// Maximum payload size for discovery frames
17pub const MAX_PAYLOAD_SIZE: usize = 256;
18
19/// Frame header structure (fixed-width)
20#[derive(Clone, Copy, Debug)]
21pub struct FrameHeader {
22    /// EtherType identifying the protocol
23    pub ether_type: u16,
24    /// Frame kind (see FrameKind enum)
25    pub kind: u8,
26    /// Protocol version
27    pub version: u8,
28    /// CRC32 checksum of payload
29    pub crc: u32,
30}
31
32impl FrameHeader {
33    const HEADER_SIZE: usize = 8; // 2 + 1 + 1 + 4 bytes
34
35    /// Create a new frame header with computed CRC
36    pub fn new(ether_type: u16, kind: u8, payload: &[u8]) -> Self {
37        let mut hasher = Hasher::new();
38        hasher.update(payload);
39
40        Self {
41            ether_type,
42            kind,
43            version: PROTOCOL_VERSION,
44            crc: hasher.finalize(),
45        }
46    }
47
48    /// Encode header as bytes (little-endian)
49    pub fn encode(&self) -> [u8; Self::HEADER_SIZE] {
50        let mut header = [0u8; Self::HEADER_SIZE];
51        header[0..2].copy_from_slice(&self.ether_type.to_le_bytes());
52        header[2] = self.kind;
53        header[3] = self.version;
54        header[4..].copy_from_slice(&self.crc.to_le_bytes());
55        header
56    }
57}
58
59/// Frame kind enumeration
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum FrameKind {
62    /// Discovery frame - announces node presence
63    Discovery = 1,
64    /// Join frame - requests to join cluster
65    Join = 2,
66    /// Attestation frame - resource verification
67    Attestation = 3,
68    /// Health check frame - periodic liveness probe
69    HealthCheck = 4,
70    /// Resource advertisement frame - capability update
71    ResourceAdvert = 5,
72}
73
74impl FrameKind {
75    const fn as_u8(&self) -> u8 {
76        match self {
77            Self::Discovery => 1,
78            Self::Join => 2,
79            Self::Attestation => 3,
80            Self::HealthCheck => 4,
81            Self::ResourceAdvert => 5,
82        }
83    }
84}
85
86impl TryFrom<u8> for FrameKind {
87    type Error = &'static str;
88
89    fn try_from(value: u8) -> Result<Self, Self::Error> {
90        match value {
91            1 => Ok(Self::Discovery),
92            2 => Ok(Self::Join),
93            3 => Ok(Self::Attestation),
94            4 => Ok(Self::HealthCheck),
95            5 => Ok(Self::ResourceAdvert),
96            _ => Err("unknown frame kind"),
97        }
98    }
99}
100
101/// Node resources structure for discovery frames
102#[derive(Clone, Debug, Default)]
103pub struct NodeResources {
104    /// Unique node identifier
105    pub id: String,
106    /// GPU VRAM in GB (f32 for precision)
107    pub vram_gb: f32,
108    /// System memory in GB
109    pub system_memory_gb: f32,
110    /// CUDA compute capability string (e.g., "8.9")
111    pub compute_capability: String,
112    /// GPU name/model
113    pub gpu_name: Option<String>,
114}
115
116impl NodeResources {
117    /// Create new node resources
118    pub fn new(
119        id: impl Into<String>,
120        vram_gb: f32,
121        system_memory_gb: f32,
122        compute_capability: impl Into<String>,
123        gpu_name: Option<String>,
124    ) -> Self {
125        Self {
126            id: id.into(),
127            vram_gb,
128            system_memory_gb,
129            compute_capability: compute_capability.into(),
130            gpu_name,
131        }
132    }
133
134    /// Serialize node resources to fixed-width binary payload
135    ///
136    /// Format: [id_len(1) + id + vram_f32_le + mem_f32_le + cc_len(1) + cc]
137    #[inline]
138    pub fn encode_payload(&self, max_size: usize) -> Vec<u8> {
139        let id_bytes = self.id.as_bytes();
140        let cc_bytes = self.compute_capability.as_bytes();
141        let gpu_bytes = self.gpu_name.as_ref().map(|name| name.as_bytes());
142
143        if id_bytes.len() > u8::MAX as usize || cc_bytes.len() > u8::MAX as usize {
144            return Vec::new();
145        }
146        if let Some(gpu_bytes) = gpu_bytes {
147            if gpu_bytes.len() > u8::MAX as usize {
148                return Vec::new();
149            }
150        }
151
152        let payload_len =
153            11 + id_bytes.len() + cc_bytes.len() + gpu_bytes.map_or(0, |bytes| 2 + bytes.len());
154        if payload_len > max_size {
155            return Vec::new();
156        }
157
158        let mut payload = Vec::with_capacity(payload_len);
159        payload.push(id_bytes.len() as u8);
160        payload.extend_from_slice(id_bytes);
161        payload.extend_from_slice(unsafe {
162            std::slice::from_raw_parts(&self.vram_gb as *const f32 as *const u8, 4)
163        });
164        payload.extend_from_slice(unsafe {
165            std::slice::from_raw_parts(&self.system_memory_gb as *const f32 as *const u8, 4)
166        });
167        payload.push(cc_bytes.len() as u8);
168        payload.extend_from_slice(cc_bytes);
169
170        if let Some(gpu_bytes) = gpu_bytes {
171            payload.push(1);
172            payload.push(gpu_bytes.len() as u8);
173            payload.extend_from_slice(gpu_bytes);
174        } else {
175            payload.push(0);
176        }
177
178        payload
179    }
180
181    /// Deserialize node resources from binary payload
182    pub fn decode_payload(payload: &[u8]) -> Result<Self, String> {
183        if payload.len() < 16 {
184            return Err("payload too short".into());
185        }
186
187        // Read ID length
188        let id_len = payload[0] as usize;
189        if id_len > payload.len() - 4 {
190            return Err("invalid ID length".into());
191        }
192
193        // Read ID
194        let id = unsafe { std::str::from_utf8_unchecked(&payload[1..1 + id_len]) };
195
196        // Read VRAM (little-endian f32)
197        let vram_bytes: [u8; 4] = payload[1 + id_len..5 + id_len].try_into().unwrap();
198        let vram_gb = f32::from_le_bytes(vram_bytes);
199
200        // Read system memory (little-endian f32)
201        let mem_bytes: [u8; 4] = payload[5 + id_len..9 + id_len].try_into().unwrap();
202        let system_memory_gb = f32::from_le_bytes(mem_bytes);
203
204        // Read compute capability length
205        let cc_len = payload[9 + id_len] as usize;
206        if cc_len > payload.len() - 10 {
207            return Err("invalid CC length".into());
208        }
209
210        // Read compute capability
211        let cc =
212            unsafe { std::str::from_utf8_unchecked(&payload[10 + id_len..10 + id_len + cc_len]) };
213
214        // Check for GPU name flag
215        let has_gpu_name = payload[10 + id_len + cc_len] == 1;
216
217        Ok(Self {
218            id: id.to_string(),
219            vram_gb,
220            system_memory_gb,
221            compute_capability: cc.to_string(),
222            gpu_name: if has_gpu_name {
223                let gpu_len = payload[10 + id_len + cc_len + 1] as usize;
224                if gpu_len > payload.len() - 12 - id_len - cc_len {
225                    return Err("invalid GPU name length".into());
226                }
227                Some(
228                    unsafe {
229                        std::str::from_utf8_unchecked(
230                            &payload[10 + id_len + cc_len + 2..10 + id_len + cc_len + 2 + gpu_len],
231                        )
232                    }
233                    .to_string(),
234                )
235            } else {
236                None
237            },
238        })
239    }
240}
241
242/// Discovery frame with binary encoding and CRC32
243#[derive(Clone, Debug)]
244pub struct DiscoveryFrame {
245    pub kind: FrameKind,
246    pub node: NodeResources,
247}
248
249impl DiscoveryFrame {
250    /// Encode discovery frame to bytes (header + payload)
251    #[inline]
252    pub fn encode(&self) -> Vec<u8> {
253        let payload = self.node.encode_payload(MAX_PAYLOAD_SIZE);
254
255        // Compute CRC32 over payload
256        let mut hasher = Hasher::new();
257        hasher.update(&payload);
258        let crc = hasher.finalize();
259
260        // Build header
261        let header = FrameHeader {
262            ether_type: GHOSTLINK_ETHERTYPE,
263            kind: self.kind.as_u8(),
264            version: PROTOCOL_VERSION,
265            crc,
266        };
267        let header_bytes = header.encode();
268
269        // Combine header and payload
270        let mut frame = Vec::with_capacity(header_bytes.len() + payload.len());
271        frame.extend_from_slice(&header_bytes);
272        frame.extend_from_slice(&payload);
273
274        frame
275    }
276
277    /// Decode discovery frame from bytes (header + payload)
278    #[inline]
279    pub fn decode(bytes: &[u8]) -> Result<Self, String> {
280        if bytes.len() < FrameHeader::HEADER_SIZE {
281            return Err("frame too short".into());
282        }
283
284        // Parse header
285        let ether_type = u16::from_le_bytes([bytes[0], bytes[1]]);
286        let kind = FrameKind::try_from(bytes[2])?;
287        let version = bytes[3];
288        let expected_crc = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
289
290        if ether_type != GHOSTLINK_ETHERTYPE {
291            return Err(format!("unexpected EtherType 0x{ether_type:04x}"));
292        }
293
294        if version != PROTOCOL_VERSION {
295            return Err(format!("unsupported protocol version {version}"));
296        }
297
298        // Parse payload with CRC verification
299        let payload_start = FrameHeader::HEADER_SIZE;
300        let payload_end = bytes.len();
301        let payload = &bytes[payload_start..payload_end];
302
303        // Compute CRC over payload
304        let mut hasher = Hasher::new();
305        hasher.update(payload);
306        let computed_crc = hasher.finalize();
307
308        if computed_crc != expected_crc {
309            return Err(format!(
310                "CRC mismatch: expected 0x{expected_crc:08x}, got 0x{computed_crc:08x}"
311            ));
312        }
313
314        // Decode node resources from payload
315        let node = NodeResources::decode_payload(payload)?;
316
317        Ok(Self { kind, node })
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn discovery_frames_round_trip() {
327        let frame = DiscoveryFrame {
328            kind: FrameKind::Join,
329            node: NodeResources::new("node-b", 12.0, 32.0, "8.6", None),
330        };
331
332        let encoded = frame.encode();
333        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
334
335        assert_eq!(decoded.kind, frame.kind);
336        assert_eq!(decoded.node.id, frame.node.id);
337        assert_eq!(decoded.node.vram_gb, frame.node.vram_gb);
338    }
339
340    #[test]
341    fn crc_verification_fails_on_modified_payload() {
342        let frame = DiscoveryFrame {
343            kind: FrameKind::Discovery,
344            node: NodeResources::new("node-a", 24.0, 64.0, "8.9", Some("RTX4090".to_string())),
345        };
346
347        let encoded = frame.encode();
348        let mut modified = encoded.clone();
349        modified[10] ^= 0xFF; // Modify payload
350
351        let result = DiscoveryFrame::decode(&modified);
352        assert!(result.is_err());
353        assert!(result.unwrap_err().contains("CRC mismatch"));
354    }
355
356    #[test]
357    fn rejects_wrong_ether_type() {
358        let mut fake_frame = vec![0u8; 10];
359        fake_frame[0] = 0xB5u8; // Low byte of GHOSTLINK_ETHERTYPE (0x88B5 LE)
360        fake_frame[1] = 0xFF; // Wrong high byte
361
362        let result = DiscoveryFrame::decode(&fake_frame);
363        assert!(result.is_err());
364    }
365
366    #[test]
367    fn handles_gpu_name_field() {
368        let frame = DiscoveryFrame {
369            kind: FrameKind::Discovery,
370            node: NodeResources::new(
371                "gpu-node-1",
372                24.0,
373                64.0,
374                "9.0",
375                Some("NVIDIA GeForce RTX 4090".to_string()),
376            ),
377        };
378
379        let encoded = frame.encode();
380        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
381
382        assert_eq!(
383            decoded.node.gpu_name,
384            Some("NVIDIA GeForce RTX 4090".to_string())
385        );
386    }
387}