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 (hardware-accelerated via crc32fast)
6//! - Sequence numbers and versioning for ordering
7//! - Zero-copy encoding with thread-local buffers
8
9use crc32fast::Hasher;
10
11/// Ghost-Link EtherType (0x88B5)
12pub const GHOSTLINK_ETHERTYPE: u16 = 0x88B5;
13
14/// Protocol version
15pub const PROTOCOL_VERSION: u8 = 1;
16
17/// Maximum payload size for discovery frames
18pub const MAX_PAYLOAD_SIZE: usize = 256;
19
20/// Maximum frame size (header + max payload)
21pub const MAX_FRAME_SIZE: usize = 8 + MAX_PAYLOAD_SIZE;
22
23/// Frame header structure (fixed-width, 8 bytes)
24#[derive(Clone, Copy, Debug)]
25pub struct FrameHeader {
26    /// EtherType identifying the protocol
27    pub ether_type: u16,
28    /// Frame kind (see FrameKind enum)
29    pub kind: u8,
30    /// Protocol version
31    pub version: u8,
32    /// CRC32 checksum of payload
33    pub crc: u32,
34}
35
36impl FrameHeader {
37    const HEADER_SIZE: usize = 8;
38
39    /// Create a new frame header with computed CRC from pre-encoded payload
40    #[inline]
41    pub fn new(ether_type: u16, kind: u8, payload: &[u8]) -> Self {
42        let crc = crc32(payload);
43        Self {
44            ether_type,
45            kind,
46            version: PROTOCOL_VERSION,
47            crc,
48        }
49    }
50
51    /// Encode header directly into a pre-allocated buffer (zero-copy)
52    ///
53    /// OPTIMIZATION: Assigns bytes directly to constant indices instead of calling `copy_from_slice`.
54    /// This eliminates slice bounds checking overhead and enables rustc/LLVM to emit optimal stores.
55    #[inline]
56    pub fn encode_into(&self, buf: &mut [u8; Self::HEADER_SIZE]) {
57        let et = self.ether_type.to_le_bytes();
58        let crc = self.crc.to_le_bytes();
59        buf[0] = et[0];
60        buf[1] = et[1];
61        buf[2] = self.kind;
62        buf[3] = self.version;
63        buf[4] = crc[0];
64        buf[5] = crc[1];
65        buf[6] = crc[2];
66        buf[7] = crc[3];
67    }
68
69    /// Decode header from bytes (zero-copy, no allocation)
70    #[inline]
71    pub fn decode(buf: &[u8]) -> Option<Self> {
72        // Optimize: Extract fixed 8-byte slice in a single bounds check to allow unaligned single-instruction loads.
73        let header_bytes: &[u8; 8] = buf.get(..Self::HEADER_SIZE)?.try_into().ok()?;
74        let ether_type = u16::from_le_bytes([header_bytes[0], header_bytes[1]]);
75        let kind = header_bytes[2];
76        let version = header_bytes[3];
77        let crc = u32::from_le_bytes([
78            header_bytes[4],
79            header_bytes[5],
80            header_bytes[6],
81            header_bytes[7],
82        ]);
83        Some(Self {
84            ether_type,
85            kind,
86            version,
87            crc,
88        })
89    }
90}
91
92/// Compute CRC32 checksum using hardware-accelerated crc32fast
93#[inline]
94pub fn crc32(data: &[u8]) -> u32 {
95    let mut hasher = Hasher::new();
96    hasher.update(data);
97    hasher.finalize()
98}
99
100/// Frame kind enumeration
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum FrameKind {
103    /// Discovery frame - announces node presence
104    Discovery = 1,
105    /// Join frame - requests to join cluster
106    Join = 2,
107    /// Attestation frame - resource verification
108    Attestation = 3,
109    /// Health check frame - periodic liveness probe
110    HealthCheck = 4,
111    /// Resource advertisement frame - capability update
112    ResourceAdvert = 5,
113}
114
115impl FrameKind {
116    const fn as_u8(&self) -> u8 {
117        match self {
118            Self::Discovery => 1,
119            Self::Join => 2,
120            Self::Attestation => 3,
121            Self::HealthCheck => 4,
122            Self::ResourceAdvert => 5,
123        }
124    }
125}
126
127impl TryFrom<u8> for FrameKind {
128    type Error = &'static str;
129
130    fn try_from(value: u8) -> Result<Self, Self::Error> {
131        match value {
132            1 => Ok(Self::Discovery),
133            2 => Ok(Self::Join),
134            3 => Ok(Self::Attestation),
135            4 => Ok(Self::HealthCheck),
136            5 => Ok(Self::ResourceAdvert),
137            _ => Err("unknown frame kind"),
138        }
139    }
140}
141
142/// Node resources structure for discovery frames
143#[derive(Clone, Debug, Default)]
144pub struct NodeResources {
145    /// Unique node identifier
146    pub id: String,
147    /// GPU VRAM in GB (f32 for precision)
148    pub vram_gb: f32,
149    /// System memory in GB
150    pub system_memory_gb: f32,
151    /// CUDA compute capability string (e.g., "8.9")
152    pub compute_capability: String,
153    /// GPU name/model
154    pub gpu_name: Option<String>,
155    /// Port this node's `ggml-rpc-server` is listening on, when it has opted
156    /// in to contributing compute to the cluster (see
157    /// `ghost_link::rpc_cluster`). `None` means this node either isn't
158    /// running one or the peer that sent this frame predates this field —
159    /// both are indistinguishable and both correctly mean "don't route
160    /// distributed inference through this node."
161    pub rpc_port: Option<u16>,
162    /// Short commit-hash fingerprint of this node's local `llama.cpp` build
163    /// (see `ghost_link::native_engine::NativeEngineClient::get_llama_build_id`),
164    /// e.g. `"da296d6"`. `None` means this node either couldn't determine its
165    /// own build id or the peer that sent this frame predates this field —
166    /// both are indistinguishable, and both are treated as "unknown, can't
167    /// verify" rather than "mismatched" by `rpc_cluster::discover_rpc_peers`
168    /// (mismatched-but-unknown must not be treated the same as a confirmed
169    /// mismatch, or every not-yet-updated peer becomes unroutable overnight).
170    /// Used to refuse routing distributed inference through a peer whose
171    /// `llama.cpp` build doesn't match the local one — version-mismatched
172    /// `ggml-rpc` peers have been confirmed on real hardware to silently
173    /// corrupt output for larger models while reporting healthy status the
174    /// whole time (see `docs/BENCHMARKS.md`'s native two-machine entry).
175    pub rpc_build_id: Option<String>,
176}
177
178impl NodeResources {
179    /// Create new node resources
180    pub fn new(
181        id: impl Into<String>,
182        vram_gb: f32,
183        system_memory_gb: f32,
184        compute_capability: impl Into<String>,
185        gpu_name: Option<String>,
186    ) -> Self {
187        Self {
188            id: id.into(),
189            vram_gb,
190            system_memory_gb,
191            compute_capability: compute_capability.into(),
192            gpu_name,
193            rpc_port: None,
194            rpc_build_id: None,
195        }
196    }
197
198    /// Builder-style setter so adding this field didn't require touching
199    /// every existing `NodeResources::new(...)` call site across the
200    /// workspace's tests and CLI commands.
201    pub fn with_rpc_port(mut self, port: u16) -> Self {
202        self.rpc_port = Some(port);
203        self
204    }
205
206    /// Builder-style setter, same rationale as `with_rpc_port`.
207    pub fn with_rpc_build_id(mut self, build_id: impl Into<String>) -> Self {
208        self.rpc_build_id = Some(build_id.into());
209        self
210    }
211
212    /// Serialize node resources into an existing binary buffer.
213    ///
214    /// Returns the length of the written payload on success, or an Error on validation failure.
215    #[inline]
216    pub fn encode_payload_into(
217        &self,
218        buffer: &mut Vec<u8>,
219        max_size: usize,
220    ) -> Result<usize, &'static str> {
221        let id_bytes = self.id.as_bytes();
222        let cc_bytes = self.compute_capability.as_bytes();
223        let gpu_bytes = self.gpu_name.as_ref().map(|name| name.as_bytes());
224        let build_id_bytes = self.rpc_build_id.as_ref().map(|id| id.as_bytes());
225
226        if id_bytes.len() > u8::MAX as usize || cc_bytes.len() > u8::MAX as usize {
227            return Err("field length exceeds u8::MAX");
228        }
229        if let Some(gpu_bytes) = gpu_bytes {
230            if gpu_bytes.len() > u8::MAX as usize {
231                return Err("GPU name length exceeds u8::MAX");
232            }
233        }
234        if let Some(build_id_bytes) = build_id_bytes {
235            if build_id_bytes.len() > u8::MAX as usize {
236                return Err("rpc_build_id length exceeds u8::MAX");
237            }
238        }
239
240        // Trailing 2 bytes: rpc_port as little-endian u16, 0 meaning "none"
241        // (real port 0 is not a valid bind target, so it's an unambiguous
242        // sentinel). Appended *after* the gpu_name section so a decoder built
243        // before this field existed simply never reads these bytes — it
244        // never checked for or rejected trailing data — and this encoder's
245        // output stays parseable by that older decoder. A decoder built
246        // after this field existed, reading an older (shorter) payload from
247        // a peer that predates it, just doesn't find the 2 extra bytes and
248        // defaults to `None` — see `decode_payload`.
249        //
250        // Then a length-prefixed rpc_build_id string (u8 length + bytes,
251        // length 0 meaning "none"/unknown), appended *after* rpc_port for the
252        // same additive-compatibility reasoning: older decoders never read
253        // past what they know about, and this decoder defaults to `None` when
254        // an older/shorter payload doesn't have these trailing bytes at all.
255        let payload_len = 11
256            + id_bytes.len()
257            + cc_bytes.len()
258            + gpu_bytes.map_or(0, |bytes| 1 + bytes.len())
259            + 2
260            + 1
261            + build_id_bytes.map_or(0, |bytes| bytes.len());
262        if payload_len > max_size {
263            return Err("payload length exceeds max_size");
264        }
265
266        // Optimize: Pre-reserve exact capacity to avoid multiple capacity reallocations and checks during serialization.
267        buffer.reserve(payload_len);
268
269        buffer.push(id_bytes.len() as u8);
270        buffer.extend_from_slice(id_bytes);
271        buffer.extend_from_slice(&self.vram_gb.to_le_bytes());
272        buffer.extend_from_slice(&self.system_memory_gb.to_le_bytes());
273        buffer.push(cc_bytes.len() as u8);
274        buffer.extend_from_slice(cc_bytes);
275
276        if let Some(gpu_bytes) = gpu_bytes {
277            buffer.push(1);
278            buffer.push(gpu_bytes.len() as u8);
279            buffer.extend_from_slice(gpu_bytes);
280        } else {
281            buffer.push(0);
282        }
283
284        buffer.extend_from_slice(&self.rpc_port.unwrap_or(0).to_le_bytes());
285
286        if let Some(build_id_bytes) = build_id_bytes {
287            buffer.push(build_id_bytes.len() as u8);
288            buffer.extend_from_slice(build_id_bytes);
289        } else {
290            buffer.push(0);
291        }
292
293        Ok(payload_len)
294    }
295
296    /// Serialize node resources to fixed-width binary payload
297    ///
298    /// Format: [id_len(1) + id + vram_f32_le + mem_f32_le + cc_len(1) + cc]
299    #[inline]
300    pub fn encode_payload(&self, max_size: usize) -> Vec<u8> {
301        let id_bytes_len = self.id.len();
302        let cc_bytes_len = self.compute_capability.len();
303        let gpu_bytes_len = self.gpu_name.as_ref().map_or(0, |name| name.len());
304        let build_id_bytes_len = self.rpc_build_id.as_ref().map_or(0, |id| id.len());
305        let est_len = 11
306            + id_bytes_len
307            + cc_bytes_len
308            + if gpu_bytes_len > 0 {
309                2 + gpu_bytes_len
310            } else {
311                0
312            }
313            + 2
314            + 1
315            + build_id_bytes_len;
316
317        let mut payload = Vec::with_capacity(est_len);
318        if self.encode_payload_into(&mut payload, max_size).is_ok() {
319            payload
320        } else {
321            Vec::new()
322        }
323    }
324
325    /// Deserialize node resources from binary payload
326    pub fn decode_payload(payload: &[u8]) -> Result<Self, String> {
327        let len = payload.len();
328        if len < 11 {
329            return Err("payload too short".into());
330        }
331
332        let mut cursor = 0usize;
333
334        let id_len = payload[cursor] as usize;
335        cursor += 1;
336        let id_end = cursor + id_len;
337        if id_end > len {
338            return Err("payload truncated".into());
339        }
340        // Optimize: Avoid allocating and copying to a temporary Vec first.
341        // Direct zero-copy slice validation using std::str::from_utf8, then allocating only on success.
342        let id = std::str::from_utf8(&payload[cursor..id_end])
343            .map_err(|_| "invalid UTF-8 in ID".to_string())?
344            .to_string();
345        cursor = id_end;
346
347        // Optimize: Extract 8-byte float array in a single bounds check, eliminating separate slice conversions and unwrap branching.
348        let vram_bytes: &[u8; 8] = payload
349            .get(cursor..cursor + 8)
350            .ok_or_else(|| "payload truncated".to_string())?
351            .try_into()
352            .map_err(|_| "payload truncated".to_string())?;
353        let vram_gb =
354            f32::from_le_bytes([vram_bytes[0], vram_bytes[1], vram_bytes[2], vram_bytes[3]]);
355        let system_memory_gb =
356            f32::from_le_bytes([vram_bytes[4], vram_bytes[5], vram_bytes[6], vram_bytes[7]]);
357        cursor += 8;
358
359        if cursor >= len {
360            return Err("payload truncated".into());
361        }
362        let cc_len = payload[cursor] as usize;
363        cursor += 1;
364        let cc_end = cursor + cc_len;
365        if cc_end > len {
366            return Err("payload truncated".into());
367        }
368        // Optimize: Zero-copy validation followed by direct to_string allocation on success.
369        let compute_capability = std::str::from_utf8(&payload[cursor..cc_end])
370            .map_err(|_| "invalid UTF-8 in CC".to_string())?
371            .to_string();
372        cursor = cc_end;
373
374        if cursor >= len {
375            return Err("payload truncated".into());
376        }
377        let has_gpu = payload[cursor] == 1;
378        cursor += 1;
379
380        let gpu_name = if has_gpu {
381            if cursor >= len {
382                return Err("payload truncated".into());
383            }
384            let gpu_len = payload[cursor] as usize;
385            cursor += 1;
386            let gpu_end = cursor + gpu_len;
387            if gpu_end > len {
388                return Err("payload truncated".into());
389            }
390            // Optimize: Zero-copy validation followed by direct to_string allocation on success.
391            let name = std::str::from_utf8(&payload[cursor..gpu_end])
392                .map_err(|_| "invalid UTF-8 in GPU name".to_string())?
393                .to_string();
394            // Was previously left unset here — harmless before this field
395            // existed (cursor was never read again), but the rpc_port read
396            // below now depends on this pointing past the GPU name bytes
397            // rather than into the middle of them.
398            cursor = gpu_end;
399            Some(name)
400        } else {
401            None
402        };
403
404        // Trailing rpc_port field, added after this format shipped — a
405        // payload from an older peer simply won't have these 2 bytes, which
406        // correctly decodes as "no RPC contribution advertised" rather than
407        // an error.
408        // Optimize: Direct array indexing avoids Result creation and try_into error mapping on verified bounds.
409        let rpc_port = if cursor + 2 <= len {
410            let port = u16::from_le_bytes([payload[cursor], payload[cursor + 1]]);
411            cursor += 2;
412            if port == 0 {
413                None
414            } else {
415                Some(port)
416            }
417        } else {
418            None
419        };
420
421        // Trailing length-prefixed rpc_build_id field, added after rpc_port
422        // shipped — same additive-compatibility reasoning: a payload from a
423        // peer that predates this field simply won't have these bytes, which
424        // correctly decodes as "unknown build" rather than an error.
425        let rpc_build_id = if cursor < len {
426            let build_id_len = payload[cursor] as usize;
427            cursor += 1;
428            let build_id_end = cursor + build_id_len;
429            if build_id_end > len {
430                return Err("payload truncated".into());
431            }
432            if build_id_len == 0 {
433                None
434            } else {
435                let id = std::str::from_utf8(&payload[cursor..build_id_end])
436                    .map_err(|_| "invalid UTF-8 in rpc_build_id".to_string())?
437                    .to_string();
438                Some(id)
439            }
440        } else {
441            None
442        };
443
444        Ok(Self {
445            id,
446            vram_gb,
447            system_memory_gb,
448            compute_capability,
449            gpu_name,
450            rpc_port,
451            rpc_build_id,
452        })
453    }
454}
455
456/// Discovery frame with binary encoding and CRC32
457#[derive(Clone, Debug)]
458pub struct DiscoveryFrame {
459    pub kind: FrameKind,
460    pub node: NodeResources,
461}
462
463impl DiscoveryFrame {
464    /// Encode discovery frame to bytes.
465    ///
466    /// OPTIMIZATION: Delegates directly to `encode_into` with a single pre-allocated `Vec`,
467    /// eliminating hand-duplicated payload serialization, avoiding stack allocation of 264 bytes,
468    /// and preventing double-copy overhead on discovery frame encoding.
469    #[inline]
470    pub fn encode(&self) -> Vec<u8> {
471        let mut buf = Vec::with_capacity(MAX_FRAME_SIZE);
472        self.encode_into(&mut buf);
473        buf
474    }
475
476    /// Encode discovery frame into a pre-allocated buffer (zero-copy hot path)
477    /// Returns the number of bytes written.
478    #[inline]
479    pub fn encode_into(&self, buf: &mut Vec<u8>) -> usize {
480        buf.clear();
481        buf.extend_from_slice(&[0u8; 8]);
482
483        if self.node.encode_payload_into(buf, MAX_PAYLOAD_SIZE).is_ok() {
484            let payload = &buf[8..];
485            let crc = crc32(payload);
486
487            let header = FrameHeader {
488                ether_type: GHOSTLINK_ETHERTYPE,
489                kind: self.kind.as_u8(),
490                version: PROTOCOL_VERSION,
491                crc,
492            };
493            // Optimize: Direct array conversion on guaranteed 8-byte slice avoids Result checking on hot path.
494            let header_buf: &mut [u8; 8] = (&mut buf[..8]).try_into().unwrap();
495            header.encode_into(header_buf);
496
497            buf.len()
498        } else {
499            buf.clear();
500            buf.extend_from_slice(&[0u8; 8]);
501            let header = FrameHeader {
502                ether_type: GHOSTLINK_ETHERTYPE,
503                kind: self.kind.as_u8(),
504                version: PROTOCOL_VERSION,
505                crc: crc32(&[]),
506            };
507            let header_buf: &mut [u8; 8] = (&mut buf[..8]).try_into().unwrap();
508            header.encode_into(header_buf);
509            buf.len()
510        }
511    }
512
513    /// Decode discovery frame from bytes (header + payload) - optimized path
514    #[inline]
515    pub fn decode(bytes: &[u8]) -> Result<Self, String> {
516        if bytes.len() < FrameHeader::HEADER_SIZE {
517            return Err("frame too short".into());
518        }
519
520        // Fast header decode using zero-copy method
521        let header = FrameHeader::decode(bytes).ok_or("invalid header")?;
522
523        if header.ether_type != GHOSTLINK_ETHERTYPE {
524            return Err(format!("unexpected EtherType 0x{:04x}", header.ether_type));
525        }
526
527        if header.version != PROTOCOL_VERSION {
528            return Err(format!("unsupported protocol version {}", header.version));
529        }
530
531        let kind = FrameKind::try_from(header.kind)?;
532
533        // Fast CRC check
534        let payload = &bytes[FrameHeader::HEADER_SIZE..];
535        let computed_crc = crc32(payload);
536
537        if computed_crc != header.crc {
538            return Err(format!(
539                "CRC mismatch: expected 0x{:08x}, got 0x{:08x}",
540                header.crc, computed_crc
541            ));
542        }
543
544        // Decode node resources from payload
545        let node = NodeResources::decode_payload(payload)?;
546
547        Ok(Self { kind, node })
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn discovery_frames_round_trip() {
557        let frame = DiscoveryFrame {
558            kind: FrameKind::Join,
559            node: NodeResources::new("node-b", 12.0, 32.0, "8.6", None),
560        };
561
562        let encoded = frame.encode();
563        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
564
565        assert_eq!(decoded.kind, frame.kind);
566        assert_eq!(decoded.node.id, frame.node.id);
567        assert_eq!(decoded.node.vram_gb, frame.node.vram_gb);
568    }
569
570    #[test]
571    fn discovery_frame_encode_round_trips_rpc_port() {
572        // Regression test: `DiscoveryFrame::encode()` is a separate,
573        // hand-duplicated serializer from `NodeResources::encode_payload_into`
574        // (kept for its zero-copy-buffer calling convention) — it silently
575        // omitted the rpc_port field entirely when that field was added,
576        // so a UDP-discovered peer's contribute-compute port never survived
577        // the wire even though the equivalent mDNS path (which does reuse
578        // the shared encoder) carried it correctly. `encode_into` already
579        // covered this path; `encode()` (what `discovery.rs`'s UDP broadcast
580        // path actually calls) did not.
581        let frame = DiscoveryFrame {
582            kind: FrameKind::Join,
583            node: NodeResources::new("node-c", 12.0, 32.0, "8.6", None).with_rpc_port(50052),
584        };
585
586        let encoded = frame.encode();
587        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
588
589        assert_eq!(decoded.node.rpc_port, Some(50052));
590    }
591
592    #[test]
593    fn discovery_frame_encode_without_rpc_port_decodes_to_none() {
594        let frame = DiscoveryFrame {
595            kind: FrameKind::Join,
596            node: NodeResources::new("node-d", 8.0, 16.0, "7.5", None),
597        };
598
599        let encoded = frame.encode();
600        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
601
602        assert_eq!(decoded.node.rpc_port, None);
603    }
604
605    #[test]
606    fn discovery_frame_encode_round_trips_rpc_build_id() {
607        // Same lesson as `discovery_frame_encode_round_trips_rpc_port` above:
608        // `DiscoveryFrame::encode()` is a hand-duplicated serializer from
609        // `NodeResources::encode_payload_into`, so a new trailing field has to
610        // be added to *both* independently or UDP discovery silently drops it
611        // while mDNS (which reuses the shared encoder) carries it correctly.
612        let frame = DiscoveryFrame {
613            kind: FrameKind::Join,
614            node: NodeResources::new("node-i", 12.0, 32.0, "8.6", None)
615                .with_rpc_build_id("da296d6"),
616        };
617
618        let encoded = frame.encode();
619        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
620
621        assert_eq!(decoded.node.rpc_build_id, Some("da296d6".to_string()));
622    }
623
624    #[test]
625    fn discovery_frame_encode_without_rpc_build_id_decodes_to_none() {
626        let frame = DiscoveryFrame {
627            kind: FrameKind::Join,
628            node: NodeResources::new("node-j", 8.0, 16.0, "7.5", None),
629        };
630
631        let encoded = frame.encode();
632        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
633
634        assert_eq!(decoded.node.rpc_build_id, None);
635    }
636
637    #[test]
638    fn discovery_frame_encode_round_trips_rpc_port_and_rpc_build_id_together() {
639        let frame = DiscoveryFrame {
640            kind: FrameKind::Join,
641            node: NodeResources::new("node-k", 12.0, 32.0, "8.6", None)
642                .with_rpc_port(50052)
643                .with_rpc_build_id("e920c523e"),
644        };
645
646        let encoded = frame.encode();
647        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
648
649        assert_eq!(decoded.node.rpc_port, Some(50052));
650        assert_eq!(decoded.node.rpc_build_id, Some("e920c523e".to_string()));
651    }
652
653    #[test]
654    fn crc_verification_fails_on_modified_payload() {
655        let frame = DiscoveryFrame {
656            kind: FrameKind::Discovery,
657            node: NodeResources::new("node-a", 24.0, 64.0, "8.9", Some("RTX4090".to_string())),
658        };
659
660        let encoded = frame.encode();
661        let mut modified = encoded.clone();
662        modified[10] ^= 0xFF; // Modify payload
663
664        let result = DiscoveryFrame::decode(&modified);
665        assert!(result.is_err());
666        assert!(result.unwrap_err().contains("CRC mismatch"));
667    }
668
669    #[test]
670    fn rejects_wrong_ether_type() {
671        let mut fake_frame = vec![0u8; 10];
672        fake_frame[0] = 0xB5u8; // Low byte of GHOSTLINK_ETHERTYPE (0x88B5 LE)
673        fake_frame[1] = 0xFF; // Wrong high byte
674
675        let result = DiscoveryFrame::decode(&fake_frame);
676        assert!(result.is_err());
677    }
678
679    #[test]
680    fn encode_payload_into_accepts_exact_max_size_with_gpu_name() {
681        // Regression test: encode_payload_into previously overcounted the
682        // gpu-name field length by 1 byte, causing it to reject payloads
683        // that actually fit exactly within max_size.
684        let node = NodeResources::new("a", 1.0, 1.0, "b", Some("c".to_string()));
685        let mut buffer = Vec::new();
686        // Actual encoded length: id_len(1)+id(1)+vram(4)+mem(4)+cc_len(1)+cc(1)
687        // +has_gpu(1)+gpu_len(1)+gpu(1)+rpc_port(2)+rpc_build_id_len(1) = 18 bytes.
688        let written = node
689            .encode_payload_into(&mut buffer, 18)
690            .expect("payload of exactly 18 bytes must fit within max_size 18");
691        assert_eq!(written, 18);
692        assert_eq!(buffer.len(), 18);
693    }
694
695    #[test]
696    fn node_resources_round_trip_preserves_rpc_port() {
697        let node = NodeResources::new("node-e", 12.0, 32.0, "8.6", None).with_rpc_port(50052);
698        let encoded = node.encode_payload(64);
699        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
700        assert_eq!(decoded.rpc_port, Some(50052));
701    }
702
703    #[test]
704    fn node_resources_round_trip_without_rpc_port_is_none() {
705        let node = NodeResources::new("node-f", 8.0, 16.0, "7.5", None);
706        let encoded = node.encode_payload(64);
707        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
708        assert_eq!(decoded.rpc_port, None);
709    }
710
711    #[test]
712    fn node_resources_round_trip_with_gpu_name_and_rpc_port_together() {
713        // Regression test: decode_payload previously never advanced `cursor`
714        // past the GPU name bytes (harmless while nothing after it was ever
715        // read), which would have made a trailing field like rpc_port read
716        // from the middle of the GPU name instead of after it.
717        let node = NodeResources::new("node-g", 24.0, 64.0, "8.9", Some("RTX 4090".to_string()))
718            .with_rpc_port(50053);
719        let encoded = node.encode_payload(128);
720        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
721        assert_eq!(decoded.gpu_name, Some("RTX 4090".to_string()));
722        assert_eq!(decoded.rpc_port, Some(50053));
723    }
724
725    #[test]
726    fn decode_payload_from_before_rpc_port_existed_defaults_to_none() {
727        // Simulates a frame from an older peer build: same format, just
728        // without the trailing 2 bytes.
729        let node = NodeResources::new("node-h", 8.0, 16.0, "7.5", None);
730        let mut encoded = node.encode_payload(64);
731        assert!(encoded.len() >= 2);
732        encoded.truncate(encoded.len() - 2);
733        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
734        assert_eq!(decoded.rpc_port, None);
735    }
736
737    #[test]
738    fn node_resources_round_trip_preserves_rpc_build_id() {
739        let node =
740            NodeResources::new("node-i", 12.0, 32.0, "8.6", None).with_rpc_build_id("da296d6");
741        let encoded = node.encode_payload(64);
742        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
743        assert_eq!(decoded.rpc_build_id, Some("da296d6".to_string()));
744    }
745
746    #[test]
747    fn node_resources_round_trip_without_rpc_build_id_is_none() {
748        let node = NodeResources::new("node-j", 8.0, 16.0, "7.5", None);
749        let encoded = node.encode_payload(64);
750        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
751        assert_eq!(decoded.rpc_build_id, None);
752    }
753
754    #[test]
755    fn node_resources_round_trip_with_gpu_name_rpc_port_and_rpc_build_id_together() {
756        // Regression test mirroring
757        // `node_resources_round_trip_with_gpu_name_and_rpc_port_together`:
758        // rpc_build_id is read *after* rpc_port, so both trailing fields must
759        // survive a payload that also has a GPU name in the middle.
760        let node = NodeResources::new("node-k", 24.0, 64.0, "8.9", Some("RTX 4090".to_string()))
761            .with_rpc_port(50053)
762            .with_rpc_build_id("e920c523e");
763        let encoded = node.encode_payload(128);
764        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
765        assert_eq!(decoded.gpu_name, Some("RTX 4090".to_string()));
766        assert_eq!(decoded.rpc_port, Some(50053));
767        assert_eq!(decoded.rpc_build_id, Some("e920c523e".to_string()));
768    }
769
770    #[test]
771    fn decode_payload_from_before_rpc_build_id_existed_defaults_to_none() {
772        // Simulates a frame from a peer build that has rpc_port but predates
773        // rpc_build_id: same format, just without the trailing length-prefix
774        // byte (and any build-id bytes).
775        let node = NodeResources::new("node-l", 8.0, 16.0, "7.5", None).with_rpc_port(50054);
776        let mut encoded = node.encode_payload(64);
777        assert!(!encoded.is_empty());
778        encoded.truncate(encoded.len() - 1);
779        let decoded = NodeResources::decode_payload(&encoded).expect("decode");
780        assert_eq!(decoded.rpc_port, Some(50054));
781        assert_eq!(decoded.rpc_build_id, None);
782    }
783
784    #[test]
785    fn decode_payload_truncated_mid_rpc_build_id_string_is_an_error() {
786        // A length prefix claiming more build-id bytes than are actually
787        // present should error, not panic or silently truncate.
788        let node =
789            NodeResources::new("node-m", 8.0, 16.0, "7.5", None).with_rpc_build_id("da296d6");
790        let mut encoded = node.encode_payload(64);
791        encoded.truncate(encoded.len() - 2); // drop the last 2 bytes of the hash
792        let result = NodeResources::decode_payload(&encoded);
793        assert!(result.is_err());
794    }
795
796    #[test]
797    fn encode_does_not_panic_when_combined_field_lengths_exceed_max_payload() {
798        // Regression test: DiscoveryFrame::encode only validated each field
799        // individually against u8::MAX, so combined field lengths could
800        // exceed MAX_PAYLOAD_SIZE and panic on the fixed-size stack buffer
801        // writes. It should now fall back to a safe header-only frame.
802        let big_id = "x".repeat(200);
803        let big_cc = "y".repeat(200);
804        let frame = DiscoveryFrame {
805            kind: FrameKind::Discovery,
806            node: NodeResources::new(big_id, 1.0, 1.0, big_cc, None),
807        };
808
809        let encoded = frame.encode();
810        assert_eq!(encoded.len(), 8, "should fall back to header-only frame");
811
812        let decoded_err = DiscoveryFrame::decode(&encoded).unwrap_err();
813        assert!(decoded_err.contains("payload too short") || decoded_err.contains("truncated"));
814    }
815
816    #[test]
817    fn handles_gpu_name_field() {
818        let frame = DiscoveryFrame {
819            kind: FrameKind::Discovery,
820            node: NodeResources::new(
821                "gpu-node-1",
822                24.0,
823                64.0,
824                "9.0",
825                Some("NVIDIA GeForce RTX 4090".to_string()),
826            ),
827        };
828
829        let encoded = frame.encode();
830        let decoded = DiscoveryFrame::decode(&encoded).unwrap();
831
832        assert_eq!(
833            decoded.node.gpu_name,
834            Some("NVIDIA GeForce RTX 4090".to_string())
835        );
836    }
837
838    // ====================================================================
839    // PROPERTY-BASED TESTS (proptest)
840    // ====================================================================
841
842    #[cfg(test)]
843    mod proptest_protocol {
844        use super::super::*;
845        use proptest::prelude::*;
846
847        proptest! {
848            /// Property: Any valid discovery frame should round-trip encode/decode
849            #[test]
850            fn prop_discovery_frame_round_trip(
851                node_id in "[a-z0-9]{1,12}",
852                vram in 1.0f32..512.0,
853                ram in 8.0f32..1024.0,
854            ) {
855                let frame = DiscoveryFrame {
856                    kind: FrameKind::Discovery,
857                    node: NodeResources::new(format!("node-{node_id}"), vram, ram, "8.9", None),
858                };
859
860                let encoded = frame.encode();
861                let decoded = DiscoveryFrame::decode(&encoded).expect("round-trip failed");
862
863                assert_eq!(decoded.node.id, frame.node.id);
864                assert!((decoded.node.vram_gb - frame.node.vram_gb).abs() < 0.01);
865                assert!((decoded.node.system_memory_gb - frame.node.system_memory_gb).abs() < 0.01);
866            }
867
868            /// Property: CRC should detect corruption with high probability
869            #[test]
870            fn prop_crc_detects_any_payload_corruption(
871                seed in 0u8..255,
872                corruption_pos in 0usize..256,
873            ) {
874                let frame = DiscoveryFrame {
875                    kind: FrameKind::Join,
876                    node: NodeResources::new("node-test", 24.0, 64.0, "8.9", None),
877                };
878
879                let mut encoded = frame.encode();
880                if corruption_pos < encoded.len() && corruption_pos < 200 {
881                    // Corrupt payload (not header/CRC area)
882                    if corruption_pos > 10 && corruption_pos < encoded.len().saturating_sub(4) {
883                        encoded[corruption_pos] = encoded[corruption_pos].wrapping_add(seed.wrapping_add(1));
884
885                        // Should fail CRC check
886                        let result = DiscoveryFrame::decode(&encoded);
887                        assert!(result.is_err(), "Corruption at byte {corruption_pos} should be detected");
888                    }
889                }
890            }
891
892            /// Property: Frame encoding is deterministic
893            #[test]
894            fn prop_frame_encoding_is_deterministic(
895                node_id in "[a-z0-9]{1,8}",
896                vram in 1.0f32..100.0,
897            ) {
898                let frame1 = DiscoveryFrame {
899                    kind: FrameKind::Discovery,
900                    node: NodeResources::new(format!("node-{node_id}"), vram, 64.0, "8.9", None),
901                };
902
903                let frame2 = DiscoveryFrame {
904                    kind: FrameKind::Discovery,
905                    node: NodeResources::new(format!("node-{node_id}"), vram, 64.0, "8.9", None),
906                };
907
908                let enc1 = frame1.encode();
909                let enc2 = frame2.encode();
910
911                // Same input should produce same output
912                assert_eq!(enc1, enc2, "Encoding should be deterministic");
913            }
914        }
915    }
916}