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]
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 #[inline]
71 pub fn decode(buf: &[u8]) -> Option<Self> {
72 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#[inline]
94pub fn crc32(data: &[u8]) -> u32 {
95 let mut hasher = Hasher::new();
96 hasher.update(data);
97 hasher.finalize()
98}
99
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum FrameKind {
103 Discovery = 1,
105 Join = 2,
107 Attestation = 3,
109 HealthCheck = 4,
111 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#[derive(Clone, Debug, Default)]
144pub struct NodeResources {
145 pub id: String,
147 pub vram_gb: f32,
149 pub system_memory_gb: f32,
151 pub compute_capability: String,
153 pub gpu_name: Option<String>,
155 pub rpc_port: Option<u16>,
162 pub rpc_build_id: Option<String>,
176}
177
178impl NodeResources {
179 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 pub fn with_rpc_port(mut self, port: u16) -> Self {
202 self.rpc_port = Some(port);
203 self
204 }
205
206 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 #[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 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 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 #[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 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 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 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 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 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 cursor = gpu_end;
399 Some(name)
400 } else {
401 None
402 };
403
404 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 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#[derive(Clone, Debug)]
458pub struct DiscoveryFrame {
459 pub kind: FrameKind,
460 pub node: NodeResources,
461}
462
463impl DiscoveryFrame {
464 #[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 #[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 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 #[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 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 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 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 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 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; 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; fake_frame[1] = 0xFF; 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 let node = NodeResources::new("a", 1.0, 1.0, "b", Some("c".to_string()));
685 let mut buffer = Vec::new();
686 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 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 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 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 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 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); 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 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 #[cfg(test)]
843 mod proptest_protocol {
844 use super::super::*;
845 use proptest::prelude::*;
846
847 proptest! {
848 #[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 #[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 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 let result = DiscoveryFrame::decode(&encoded);
887 assert!(result.is_err(), "Corruption at byte {corruption_pos} should be detected");
888 }
889 }
890 }
891
892 #[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 assert_eq!(enc1, enc2, "Encoding should be deterministic");
913 }
914 }
915 }
916}