1use crc32fast::Hasher;
10
11pub const GHOSTLINK_ETHERTYPE: u16 = 0x88B5;
13
14pub const PROTOCOL_VERSION: u8 = 1;
16
17pub const MAX_PAYLOAD_SIZE: usize = 256;
19
20pub const MAX_FRAME_SIZE: usize = 8 + MAX_PAYLOAD_SIZE;
22
23#[derive(Clone, Copy, Debug)]
25pub struct FrameHeader {
26 pub ether_type: u16,
28 pub kind: u8,
30 pub version: u8,
32 pub crc: u32,
34}
35
36impl FrameHeader {
37 const HEADER_SIZE: usize = 8;
38
39 #[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 #[inline]
53 pub fn encode_into(&self, buf: &mut [u8; Self::HEADER_SIZE]) {
54 let et_bytes = self.ether_type.to_le_bytes();
55 buf[0] = et_bytes[0];
56 buf[1] = et_bytes[1];
57 buf[2] = self.kind;
58 buf[3] = self.version;
59 let crc_bytes = self.crc.to_le_bytes();
60 buf[4] = crc_bytes[0];
61 buf[5] = crc_bytes[1];
62 buf[6] = crc_bytes[2];
63 buf[7] = crc_bytes[3];
64 }
65
66 #[inline]
68 pub fn decode(buf: &[u8]) -> Option<Self> {
69 if buf.len() < Self::HEADER_SIZE {
70 return None;
71 }
72 let ether_type = u16::from_le_bytes(buf[0..2].try_into().ok()?);
75 let kind = buf[2];
76 let version = buf[3];
77 let crc = u32::from_le_bytes(buf[4..8].try_into().ok()?);
78 Some(Self {
79 ether_type,
80 kind,
81 version,
82 crc,
83 })
84 }
85}
86
87#[inline]
89pub fn crc32(data: &[u8]) -> u32 {
90 let mut hasher = Hasher::new();
91 hasher.update(data);
92 hasher.finalize()
93}
94
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum FrameKind {
98 Discovery = 1,
100 Join = 2,
102 Attestation = 3,
104 HealthCheck = 4,
106 ResourceAdvert = 5,
108}
109
110impl FrameKind {
111 const fn as_u8(&self) -> u8 {
112 match self {
113 Self::Discovery => 1,
114 Self::Join => 2,
115 Self::Attestation => 3,
116 Self::HealthCheck => 4,
117 Self::ResourceAdvert => 5,
118 }
119 }
120}
121
122impl TryFrom<u8> for FrameKind {
123 type Error = &'static str;
124
125 fn try_from(value: u8) -> Result<Self, Self::Error> {
126 match value {
127 1 => Ok(Self::Discovery),
128 2 => Ok(Self::Join),
129 3 => Ok(Self::Attestation),
130 4 => Ok(Self::HealthCheck),
131 5 => Ok(Self::ResourceAdvert),
132 _ => Err("unknown frame kind"),
133 }
134 }
135}
136
137#[derive(Clone, Debug, Default)]
139pub struct NodeResources {
140 pub id: String,
142 pub vram_gb: f32,
144 pub system_memory_gb: f32,
146 pub compute_capability: String,
148 pub gpu_name: Option<String>,
150 pub rpc_port: Option<u16>,
157 pub rpc_build_id: Option<String>,
171}
172
173impl NodeResources {
174 pub fn new(
176 id: impl Into<String>,
177 vram_gb: f32,
178 system_memory_gb: f32,
179 compute_capability: impl Into<String>,
180 gpu_name: Option<String>,
181 ) -> Self {
182 Self {
183 id: id.into(),
184 vram_gb,
185 system_memory_gb,
186 compute_capability: compute_capability.into(),
187 gpu_name,
188 rpc_port: None,
189 rpc_build_id: None,
190 }
191 }
192
193 pub fn with_rpc_port(mut self, port: u16) -> Self {
197 self.rpc_port = Some(port);
198 self
199 }
200
201 pub fn with_rpc_build_id(mut self, build_id: impl Into<String>) -> Self {
203 self.rpc_build_id = Some(build_id.into());
204 self
205 }
206
207 #[inline]
211 pub fn encode_payload_into(
212 &self,
213 buffer: &mut Vec<u8>,
214 max_size: usize,
215 ) -> Result<usize, &'static str> {
216 let id_bytes = self.id.as_bytes();
217 let cc_bytes = self.compute_capability.as_bytes();
218 let gpu_bytes = self.gpu_name.as_ref().map(|name| name.as_bytes());
219 let build_id_bytes = self.rpc_build_id.as_ref().map(|id| id.as_bytes());
220
221 if id_bytes.len() > u8::MAX as usize || cc_bytes.len() > u8::MAX as usize {
222 return Err("field length exceeds u8::MAX");
223 }
224 if let Some(gpu_bytes) = gpu_bytes {
225 if gpu_bytes.len() > u8::MAX as usize {
226 return Err("GPU name length exceeds u8::MAX");
227 }
228 }
229 if let Some(build_id_bytes) = build_id_bytes {
230 if build_id_bytes.len() > u8::MAX as usize {
231 return Err("rpc_build_id length exceeds u8::MAX");
232 }
233 }
234
235 let payload_len = 11
251 + id_bytes.len()
252 + cc_bytes.len()
253 + gpu_bytes.map_or(0, |bytes| 1 + bytes.len())
254 + 2
255 + 1
256 + build_id_bytes.map_or(0, |bytes| bytes.len());
257 if payload_len > max_size {
258 return Err("payload length exceeds max_size");
259 }
260
261 buffer.push(id_bytes.len() as u8);
262 buffer.extend_from_slice(id_bytes);
263 buffer.extend_from_slice(&self.vram_gb.to_le_bytes());
264 buffer.extend_from_slice(&self.system_memory_gb.to_le_bytes());
265 buffer.push(cc_bytes.len() as u8);
266 buffer.extend_from_slice(cc_bytes);
267
268 if let Some(gpu_bytes) = gpu_bytes {
269 buffer.push(1);
270 buffer.push(gpu_bytes.len() as u8);
271 buffer.extend_from_slice(gpu_bytes);
272 } else {
273 buffer.push(0);
274 }
275
276 buffer.extend_from_slice(&self.rpc_port.unwrap_or(0).to_le_bytes());
277
278 if let Some(build_id_bytes) = build_id_bytes {
279 buffer.push(build_id_bytes.len() as u8);
280 buffer.extend_from_slice(build_id_bytes);
281 } else {
282 buffer.push(0);
283 }
284
285 Ok(payload_len)
286 }
287
288 #[inline]
292 pub fn encode_payload(&self, max_size: usize) -> Vec<u8> {
293 let id_bytes_len = self.id.len();
294 let cc_bytes_len = self.compute_capability.len();
295 let gpu_bytes_len = self.gpu_name.as_ref().map_or(0, |name| name.len());
296 let build_id_bytes_len = self.rpc_build_id.as_ref().map_or(0, |id| id.len());
297 let est_len = 11
298 + id_bytes_len
299 + cc_bytes_len
300 + if gpu_bytes_len > 0 {
301 2 + gpu_bytes_len
302 } else {
303 0
304 }
305 + 2
306 + 1
307 + build_id_bytes_len;
308
309 let mut payload = Vec::with_capacity(est_len);
310 if self.encode_payload_into(&mut payload, max_size).is_ok() {
311 payload
312 } else {
313 Vec::new()
314 }
315 }
316
317 pub fn decode_payload(payload: &[u8]) -> Result<Self, String> {
319 let len = payload.len();
320 if len < 11 {
321 return Err("payload too short".into());
322 }
323
324 let mut cursor = 0usize;
325
326 let id_len = payload[cursor] as usize;
327 cursor += 1;
328 let id_end = cursor + id_len;
329 if id_end > len {
330 return Err("payload truncated".into());
331 }
332 let id = std::str::from_utf8(&payload[cursor..id_end])
335 .map_err(|_| "invalid UTF-8 in ID".to_string())?
336 .to_string();
337 cursor = id_end;
338
339 let vram_end = cursor + 8;
340 if vram_end > len {
341 return Err("payload truncated".into());
342 }
343 let vram_gb = f32::from_le_bytes(
346 payload[cursor..cursor + 4]
347 .try_into()
348 .map_err(|_| "vram parsing failed".to_string())?,
349 );
350 let system_memory_gb = f32::from_le_bytes(
351 payload[cursor + 4..cursor + 8]
352 .try_into()
353 .map_err(|_| "system memory parsing failed".to_string())?,
354 );
355 cursor = vram_end;
356
357 if cursor >= len {
358 return Err("payload truncated".into());
359 }
360 let cc_len = payload[cursor] as usize;
361 cursor += 1;
362 let cc_end = cursor + cc_len;
363 if cc_end > len {
364 return Err("payload truncated".into());
365 }
366 let compute_capability = std::str::from_utf8(&payload[cursor..cc_end])
368 .map_err(|_| "invalid UTF-8 in CC".to_string())?
369 .to_string();
370 cursor = cc_end;
371
372 if cursor >= len {
373 return Err("payload truncated".into());
374 }
375 let has_gpu = payload[cursor] == 1;
376 cursor += 1;
377
378 let gpu_name = if has_gpu {
379 if cursor >= len {
380 return Err("payload truncated".into());
381 }
382 let gpu_len = payload[cursor] as usize;
383 cursor += 1;
384 let gpu_end = cursor + gpu_len;
385 if gpu_end > len {
386 return Err("payload truncated".into());
387 }
388 let name = std::str::from_utf8(&payload[cursor..gpu_end])
390 .map_err(|_| "invalid UTF-8 in GPU name".to_string())?
391 .to_string();
392 cursor = gpu_end;
397 Some(name)
398 } else {
399 None
400 };
401
402 let rpc_port = if cursor + 2 <= len {
408 let port = u16::from_le_bytes(
409 payload[cursor..cursor + 2]
410 .try_into()
411 .map_err(|_| "rpc_port parsing failed".to_string())?,
412 );
413 cursor += 2;
414 if port == 0 {
415 None
416 } else {
417 Some(port)
418 }
419 } else {
420 None
421 };
422
423 let rpc_build_id = if cursor < len {
428 let build_id_len = payload[cursor] as usize;
429 cursor += 1;
430 let build_id_end = cursor + build_id_len;
431 if build_id_end > len {
432 return Err("payload truncated".into());
433 }
434 if build_id_len == 0 {
435 None
436 } else {
437 let id = std::str::from_utf8(&payload[cursor..build_id_end])
438 .map_err(|_| "invalid UTF-8 in rpc_build_id".to_string())?
439 .to_string();
440 Some(id)
441 }
442 } else {
443 None
444 };
445
446 Ok(Self {
447 id,
448 vram_gb,
449 system_memory_gb,
450 compute_capability,
451 gpu_name,
452 rpc_port,
453 rpc_build_id,
454 })
455 }
456}
457
458#[derive(Clone, Debug)]
460pub struct DiscoveryFrame {
461 pub kind: FrameKind,
462 pub node: NodeResources,
463}
464
465impl DiscoveryFrame {
466 #[inline]
469 pub fn encode(&self) -> Vec<u8> {
470 let mut buf = [0u8; MAX_FRAME_SIZE];
471 let et = GHOSTLINK_ETHERTYPE.to_le_bytes();
472 buf[0] = et[0];
473 buf[1] = et[1];
474 buf[2] = self.kind.as_u8();
475 buf[3] = PROTOCOL_VERSION;
476
477 let id_bytes = self.node.id.as_bytes();
478 let cc_bytes = self.node.compute_capability.as_bytes();
479
480 if id_bytes.len() > u8::MAX as usize || cc_bytes.len() > u8::MAX as usize {
481 buf[4..8].copy_from_slice(&crc32(&[]).to_le_bytes());
482 return buf[..8].to_vec();
483 }
484 if let Some(ref gpu_name) = self.node.gpu_name {
485 if gpu_name.len() > u8::MAX as usize {
486 buf[4..8].copy_from_slice(&crc32(&[]).to_le_bytes());
487 return buf[..8].to_vec();
488 }
489 }
490 if let Some(ref build_id) = self.node.rpc_build_id {
491 if build_id.len() > u8::MAX as usize {
492 buf[4..8].copy_from_slice(&crc32(&[]).to_le_bytes());
493 return buf[..8].to_vec();
494 }
495 }
496
497 let gpu_len = self.node.gpu_name.as_ref().map(|name| name.len());
502 let build_id_len = self.node.rpc_build_id.as_ref().map(|id| id.len());
514 let payload_len = 11
515 + id_bytes.len()
516 + cc_bytes.len()
517 + gpu_len.map_or(0, |len| 1 + len)
518 + 2
519 + 1
520 + build_id_len.unwrap_or(0);
521 if payload_len > MAX_PAYLOAD_SIZE {
522 buf[4..8].copy_from_slice(&crc32(&[]).to_le_bytes());
523 return buf[..8].to_vec();
524 }
525
526 let mut pos = 8usize;
527
528 buf[pos] = id_bytes.len() as u8;
529 pos += 1;
530 buf[pos..pos + id_bytes.len()].copy_from_slice(id_bytes);
531 pos += id_bytes.len();
532
533 buf[pos..pos + 4].copy_from_slice(&self.node.vram_gb.to_le_bytes());
534 pos += 4;
535
536 buf[pos..pos + 4].copy_from_slice(&self.node.system_memory_gb.to_le_bytes());
537 pos += 4;
538
539 buf[pos] = cc_bytes.len() as u8;
540 pos += 1;
541 buf[pos..pos + cc_bytes.len()].copy_from_slice(cc_bytes);
542 pos += cc_bytes.len();
543
544 if let Some(ref gpu_name) = self.node.gpu_name {
545 let gpu_bytes = gpu_name.as_bytes();
546 buf[pos] = 1;
547 pos += 1;
548 buf[pos] = gpu_bytes.len() as u8;
549 pos += 1;
550 buf[pos..pos + gpu_bytes.len()].copy_from_slice(gpu_bytes);
551 pos += gpu_bytes.len();
552 } else {
553 buf[pos] = 0;
554 pos += 1;
555 }
556
557 let rpc_port_bytes = self.node.rpc_port.unwrap_or(0).to_le_bytes();
558 buf[pos..pos + 2].copy_from_slice(&rpc_port_bytes);
559 pos += 2;
560
561 if let Some(ref build_id) = self.node.rpc_build_id {
562 let build_id_bytes = build_id.as_bytes();
563 buf[pos] = build_id_bytes.len() as u8;
564 pos += 1;
565 buf[pos..pos + build_id_bytes.len()].copy_from_slice(build_id_bytes);
566 pos += build_id_bytes.len();
567 } else {
568 buf[pos] = 0;
569 pos += 1;
570 }
571
572 let crc = crc32(&buf[8..pos]);
573 buf[4..8].copy_from_slice(&crc.to_le_bytes());
574
575 buf[..pos].to_vec()
576 }
577
578 #[inline]
581 pub fn encode_into(&self, buf: &mut Vec<u8>) -> usize {
582 buf.clear();
583 buf.extend_from_slice(&[0u8; 8]);
584
585 if self.node.encode_payload_into(buf, MAX_PAYLOAD_SIZE).is_ok() {
586 let payload = &buf[8..];
587 let crc = crc32(payload);
588
589 let header = FrameHeader {
590 ether_type: GHOSTLINK_ETHERTYPE,
591 kind: self.kind.as_u8(),
592 version: PROTOCOL_VERSION,
593 crc,
594 };
595 header.encode_into(unsafe { &mut *(buf.as_mut_ptr() as *mut [u8; 8]) });
596
597 buf.len()
598 } else {
599 buf.clear();
600 buf.extend_from_slice(&[0u8; 8]);
601 let header = FrameHeader {
602 ether_type: GHOSTLINK_ETHERTYPE,
603 kind: self.kind.as_u8(),
604 version: PROTOCOL_VERSION,
605 crc: crc32(&[]),
606 };
607 header.encode_into(unsafe { &mut *(buf.as_mut_ptr() as *mut [u8; 8]) });
608 buf.len()
609 }
610 }
611
612 #[inline]
614 pub fn decode(bytes: &[u8]) -> Result<Self, String> {
615 if bytes.len() < FrameHeader::HEADER_SIZE {
616 return Err("frame too short".into());
617 }
618
619 let header = FrameHeader::decode(bytes).ok_or("invalid header")?;
621
622 if header.ether_type != GHOSTLINK_ETHERTYPE {
623 return Err(format!("unexpected EtherType 0x{:04x}", header.ether_type));
624 }
625
626 if header.version != PROTOCOL_VERSION {
627 return Err(format!("unsupported protocol version {}", header.version));
628 }
629
630 let payload = &bytes[FrameHeader::HEADER_SIZE..];
632 let computed_crc = crc32(payload);
633
634 if computed_crc != header.crc {
635 return Err(format!(
636 "CRC mismatch: expected 0x{:08x}, got 0x{:08x}",
637 header.crc, computed_crc
638 ));
639 }
640
641 let node = NodeResources::decode_payload(payload)?;
643
644 Ok(Self {
645 kind: FrameKind::try_from(header.kind)?,
646 node,
647 })
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
656 fn discovery_frames_round_trip() {
657 let frame = DiscoveryFrame {
658 kind: FrameKind::Join,
659 node: NodeResources::new("node-b", 12.0, 32.0, "8.6", None),
660 };
661
662 let encoded = frame.encode();
663 let decoded = DiscoveryFrame::decode(&encoded).unwrap();
664
665 assert_eq!(decoded.kind, frame.kind);
666 assert_eq!(decoded.node.id, frame.node.id);
667 assert_eq!(decoded.node.vram_gb, frame.node.vram_gb);
668 }
669
670 #[test]
671 fn discovery_frame_encode_round_trips_rpc_port() {
672 let frame = DiscoveryFrame {
682 kind: FrameKind::Join,
683 node: NodeResources::new("node-c", 12.0, 32.0, "8.6", None).with_rpc_port(50052),
684 };
685
686 let encoded = frame.encode();
687 let decoded = DiscoveryFrame::decode(&encoded).unwrap();
688
689 assert_eq!(decoded.node.rpc_port, Some(50052));
690 }
691
692 #[test]
693 fn discovery_frame_encode_without_rpc_port_decodes_to_none() {
694 let frame = DiscoveryFrame {
695 kind: FrameKind::Join,
696 node: NodeResources::new("node-d", 8.0, 16.0, "7.5", None),
697 };
698
699 let encoded = frame.encode();
700 let decoded = DiscoveryFrame::decode(&encoded).unwrap();
701
702 assert_eq!(decoded.node.rpc_port, None);
703 }
704
705 #[test]
706 fn discovery_frame_encode_round_trips_rpc_build_id() {
707 let frame = DiscoveryFrame {
713 kind: FrameKind::Join,
714 node: NodeResources::new("node-i", 12.0, 32.0, "8.6", None)
715 .with_rpc_build_id("da296d6"),
716 };
717
718 let encoded = frame.encode();
719 let decoded = DiscoveryFrame::decode(&encoded).unwrap();
720
721 assert_eq!(decoded.node.rpc_build_id, Some("da296d6".to_string()));
722 }
723
724 #[test]
725 fn discovery_frame_encode_without_rpc_build_id_decodes_to_none() {
726 let frame = DiscoveryFrame {
727 kind: FrameKind::Join,
728 node: NodeResources::new("node-j", 8.0, 16.0, "7.5", None),
729 };
730
731 let encoded = frame.encode();
732 let decoded = DiscoveryFrame::decode(&encoded).unwrap();
733
734 assert_eq!(decoded.node.rpc_build_id, None);
735 }
736
737 #[test]
738 fn discovery_frame_encode_round_trips_rpc_port_and_rpc_build_id_together() {
739 let frame = DiscoveryFrame {
740 kind: FrameKind::Join,
741 node: NodeResources::new("node-k", 12.0, 32.0, "8.6", None)
742 .with_rpc_port(50052)
743 .with_rpc_build_id("e920c523e"),
744 };
745
746 let encoded = frame.encode();
747 let decoded = DiscoveryFrame::decode(&encoded).unwrap();
748
749 assert_eq!(decoded.node.rpc_port, Some(50052));
750 assert_eq!(decoded.node.rpc_build_id, Some("e920c523e".to_string()));
751 }
752
753 #[test]
754 fn crc_verification_fails_on_modified_payload() {
755 let frame = DiscoveryFrame {
756 kind: FrameKind::Discovery,
757 node: NodeResources::new("node-a", 24.0, 64.0, "8.9", Some("RTX4090".to_string())),
758 };
759
760 let encoded = frame.encode();
761 let mut modified = encoded.clone();
762 modified[10] ^= 0xFF; let result = DiscoveryFrame::decode(&modified);
765 assert!(result.is_err());
766 assert!(result.unwrap_err().contains("CRC mismatch"));
767 }
768
769 #[test]
770 fn rejects_wrong_ether_type() {
771 let mut fake_frame = vec![0u8; 10];
772 fake_frame[0] = 0xB5u8; fake_frame[1] = 0xFF; let result = DiscoveryFrame::decode(&fake_frame);
776 assert!(result.is_err());
777 }
778
779 #[test]
780 fn encode_payload_into_accepts_exact_max_size_with_gpu_name() {
781 let node = NodeResources::new("a", 1.0, 1.0, "b", Some("c".to_string()));
785 let mut buffer = Vec::new();
786 let written = node
789 .encode_payload_into(&mut buffer, 18)
790 .expect("payload of exactly 18 bytes must fit within max_size 18");
791 assert_eq!(written, 18);
792 assert_eq!(buffer.len(), 18);
793 }
794
795 #[test]
796 fn node_resources_round_trip_preserves_rpc_port() {
797 let node = NodeResources::new("node-e", 12.0, 32.0, "8.6", None).with_rpc_port(50052);
798 let encoded = node.encode_payload(64);
799 let decoded = NodeResources::decode_payload(&encoded).expect("decode");
800 assert_eq!(decoded.rpc_port, Some(50052));
801 }
802
803 #[test]
804 fn node_resources_round_trip_without_rpc_port_is_none() {
805 let node = NodeResources::new("node-f", 8.0, 16.0, "7.5", None);
806 let encoded = node.encode_payload(64);
807 let decoded = NodeResources::decode_payload(&encoded).expect("decode");
808 assert_eq!(decoded.rpc_port, None);
809 }
810
811 #[test]
812 fn node_resources_round_trip_with_gpu_name_and_rpc_port_together() {
813 let node = NodeResources::new("node-g", 24.0, 64.0, "8.9", Some("RTX 4090".to_string()))
818 .with_rpc_port(50053);
819 let encoded = node.encode_payload(128);
820 let decoded = NodeResources::decode_payload(&encoded).expect("decode");
821 assert_eq!(decoded.gpu_name, Some("RTX 4090".to_string()));
822 assert_eq!(decoded.rpc_port, Some(50053));
823 }
824
825 #[test]
826 fn decode_payload_from_before_rpc_port_existed_defaults_to_none() {
827 let node = NodeResources::new("node-h", 8.0, 16.0, "7.5", None);
830 let mut encoded = node.encode_payload(64);
831 assert!(encoded.len() >= 2);
832 encoded.truncate(encoded.len() - 2);
833 let decoded = NodeResources::decode_payload(&encoded).expect("decode");
834 assert_eq!(decoded.rpc_port, None);
835 }
836
837 #[test]
838 fn node_resources_round_trip_preserves_rpc_build_id() {
839 let node =
840 NodeResources::new("node-i", 12.0, 32.0, "8.6", None).with_rpc_build_id("da296d6");
841 let encoded = node.encode_payload(64);
842 let decoded = NodeResources::decode_payload(&encoded).expect("decode");
843 assert_eq!(decoded.rpc_build_id, Some("da296d6".to_string()));
844 }
845
846 #[test]
847 fn node_resources_round_trip_without_rpc_build_id_is_none() {
848 let node = NodeResources::new("node-j", 8.0, 16.0, "7.5", None);
849 let encoded = node.encode_payload(64);
850 let decoded = NodeResources::decode_payload(&encoded).expect("decode");
851 assert_eq!(decoded.rpc_build_id, None);
852 }
853
854 #[test]
855 fn node_resources_round_trip_with_gpu_name_rpc_port_and_rpc_build_id_together() {
856 let node = NodeResources::new("node-k", 24.0, 64.0, "8.9", Some("RTX 4090".to_string()))
861 .with_rpc_port(50053)
862 .with_rpc_build_id("e920c523e");
863 let encoded = node.encode_payload(128);
864 let decoded = NodeResources::decode_payload(&encoded).expect("decode");
865 assert_eq!(decoded.gpu_name, Some("RTX 4090".to_string()));
866 assert_eq!(decoded.rpc_port, Some(50053));
867 assert_eq!(decoded.rpc_build_id, Some("e920c523e".to_string()));
868 }
869
870 #[test]
871 fn decode_payload_from_before_rpc_build_id_existed_defaults_to_none() {
872 let node = NodeResources::new("node-l", 8.0, 16.0, "7.5", None).with_rpc_port(50054);
876 let mut encoded = node.encode_payload(64);
877 assert!(!encoded.is_empty());
878 encoded.truncate(encoded.len() - 1);
879 let decoded = NodeResources::decode_payload(&encoded).expect("decode");
880 assert_eq!(decoded.rpc_port, Some(50054));
881 assert_eq!(decoded.rpc_build_id, None);
882 }
883
884 #[test]
885 fn decode_payload_truncated_mid_rpc_build_id_string_is_an_error() {
886 let node =
889 NodeResources::new("node-m", 8.0, 16.0, "7.5", None).with_rpc_build_id("da296d6");
890 let mut encoded = node.encode_payload(64);
891 encoded.truncate(encoded.len() - 2); let result = NodeResources::decode_payload(&encoded);
893 assert!(result.is_err());
894 }
895
896 #[test]
897 fn encode_does_not_panic_when_combined_field_lengths_exceed_max_payload() {
898 let big_id = "x".repeat(200);
903 let big_cc = "y".repeat(200);
904 let frame = DiscoveryFrame {
905 kind: FrameKind::Discovery,
906 node: NodeResources::new(big_id, 1.0, 1.0, big_cc, None),
907 };
908
909 let encoded = frame.encode();
910 assert_eq!(encoded.len(), 8, "should fall back to header-only frame");
911
912 let decoded_err = DiscoveryFrame::decode(&encoded).unwrap_err();
913 assert!(decoded_err.contains("payload too short") || decoded_err.contains("truncated"));
914 }
915
916 #[test]
917 fn handles_gpu_name_field() {
918 let frame = DiscoveryFrame {
919 kind: FrameKind::Discovery,
920 node: NodeResources::new(
921 "gpu-node-1",
922 24.0,
923 64.0,
924 "9.0",
925 Some("NVIDIA GeForce RTX 4090".to_string()),
926 ),
927 };
928
929 let encoded = frame.encode();
930 let decoded = DiscoveryFrame::decode(&encoded).unwrap();
931
932 assert_eq!(
933 decoded.node.gpu_name,
934 Some("NVIDIA GeForce RTX 4090".to_string())
935 );
936 }
937
938 #[cfg(test)]
943 mod proptest_protocol {
944 use super::super::*;
945 use proptest::prelude::*;
946
947 proptest! {
948 #[test]
950 fn prop_discovery_frame_round_trip(
951 node_id in "[a-z0-9]{1,12}",
952 vram in 1.0f32..512.0,
953 ram in 8.0f32..1024.0,
954 ) {
955 let frame = DiscoveryFrame {
956 kind: FrameKind::Discovery,
957 node: NodeResources::new(format!("node-{node_id}"), vram, ram, "8.9", None),
958 };
959
960 let encoded = frame.encode();
961 let decoded = DiscoveryFrame::decode(&encoded).expect("round-trip failed");
962
963 assert_eq!(decoded.node.id, frame.node.id);
964 assert!((decoded.node.vram_gb - frame.node.vram_gb).abs() < 0.01);
965 assert!((decoded.node.system_memory_gb - frame.node.system_memory_gb).abs() < 0.01);
966 }
967
968 #[test]
970 fn prop_crc_detects_any_payload_corruption(
971 seed in 0u8..255,
972 corruption_pos in 0usize..256,
973 ) {
974 let frame = DiscoveryFrame {
975 kind: FrameKind::Join,
976 node: NodeResources::new("node-test", 24.0, 64.0, "8.9", None),
977 };
978
979 let mut encoded = frame.encode();
980 if corruption_pos < encoded.len() && corruption_pos < 200 {
981 if corruption_pos > 10 && corruption_pos < encoded.len().saturating_sub(4) {
983 encoded[corruption_pos] = encoded[corruption_pos].wrapping_add(seed.wrapping_add(1));
984
985 let result = DiscoveryFrame::decode(&encoded);
987 assert!(result.is_err(), "Corruption at byte {corruption_pos} should be detected");
988 }
989 }
990 }
991
992 #[test]
994 fn prop_frame_encoding_is_deterministic(
995 node_id in "[a-z0-9]{1,8}",
996 vram in 1.0f32..100.0,
997 ) {
998 let frame1 = DiscoveryFrame {
999 kind: FrameKind::Discovery,
1000 node: NodeResources::new(format!("node-{node_id}"), vram, 64.0, "8.9", None),
1001 };
1002
1003 let frame2 = DiscoveryFrame {
1004 kind: FrameKind::Discovery,
1005 node: NodeResources::new(format!("node-{node_id}"), vram, 64.0, "8.9", None),
1006 };
1007
1008 let enc1 = frame1.encode();
1009 let enc2 = frame2.encode();
1010
1011 assert_eq!(enc1, enc2, "Encoding should be deterministic");
1013 }
1014 }
1015 }
1016}