Skip to main content

fips_core/transport/ethernet/
mod.rs

1//! Ethernet Transport Implementation
2//!
3//! Provides raw Ethernet transport for FIPS peer communication. On Linux,
4//! uses AF_PACKET/SOCK_DGRAM sockets; on macOS, uses BPF devices (`/dev/bpf*`).
5//! Works on wired Ethernet and WiFi interfaces (kernel mac80211 abstracts
6//! 802.11 transparently on Linux).
7
8pub mod discovery;
9pub mod socket;
10pub mod stats;
11
12use super::{
13    DiscoveredPeer, PacketBuffer, PacketTx, ReceivedPacket, Transport, TransportAddr,
14    TransportError, TransportId, TransportState, TransportType,
15};
16use crate::config::EthernetConfig;
17use discovery::{
18    DiscoveryBuffer, FRAME_TYPE_BEACON, FRAME_TYPE_DATA, build_scoped_beacon, parse_beacon_record,
19};
20use socket::{AsyncPacketSocket, ETHERNET_BROADCAST, PacketSocket};
21use stats::EthernetStats;
22
23use secp256k1::XOnlyPublicKey;
24use std::sync::Arc;
25use tokio::task::JoinHandle;
26use tracing::{debug, info, trace, warn};
27
28/// Ethernet transport for FIPS.
29///
30/// Uses AF_PACKET with SOCK_DGRAM for raw Ethernet frame I/O. A single
31/// socket per interface serves all peers; links are virtual tuples of
32/// (transport_id, remote_mac).
33pub struct EthernetTransport {
34    /// Unique transport identifier.
35    transport_id: TransportId,
36    /// Optional instance name (for named instances in config).
37    name: Option<String>,
38    /// Configuration.
39    config: EthernetConfig,
40    /// Current state.
41    state: TransportState,
42    /// Async socket (None until started).
43    socket: Option<Arc<AsyncPacketSocket>>,
44    /// Channel for delivering received packets to Node.
45    packet_tx: PacketTx,
46    /// Receive loop task handle.
47    recv_task: Option<JoinHandle<()>>,
48    /// Beacon sender task handle.
49    beacon_task: Option<JoinHandle<()>>,
50    /// Local MAC address (after start).
51    local_mac: Option<[u8; 6]>,
52    /// Interface name (from config).
53    interface: String,
54    /// Effective MTU (interface MTU - 1 for frame type prefix).
55    effective_mtu: u16,
56    /// Discovery buffer for discovered peers.
57    discovery_buffer: Arc<DiscoveryBuffer>,
58    /// Transport-level statistics.
59    stats: Arc<EthernetStats>,
60    /// Node's public key for beacon construction.
61    local_pubkey: Option<XOnlyPublicKey>,
62}
63
64impl EthernetTransport {
65    /// Create a new Ethernet transport.
66    pub fn new(
67        transport_id: TransportId,
68        name: Option<String>,
69        config: EthernetConfig,
70        packet_tx: PacketTx,
71    ) -> Self {
72        let interface = config.interface.clone();
73        let discovery_buffer = Arc::new(DiscoveryBuffer::new(
74            transport_id,
75            config.discovery_scope().map(str::to_string),
76        ));
77        let stats = Arc::new(EthernetStats::new());
78
79        Self {
80            transport_id,
81            name,
82            config,
83            state: TransportState::Configured,
84            socket: None,
85            packet_tx,
86            recv_task: None,
87            beacon_task: None,
88            local_mac: None,
89            interface,
90            effective_mtu: 1497, // default, updated on start
91            discovery_buffer,
92            stats,
93            local_pubkey: None,
94        }
95    }
96
97    /// Get the instance name (if configured as a named instance).
98    pub fn name(&self) -> Option<&str> {
99        self.name.as_deref()
100    }
101
102    /// Get the interface name.
103    pub fn interface_name(&self) -> &str {
104        &self.interface
105    }
106
107    /// Get the local MAC address (only valid after start).
108    pub fn local_mac(&self) -> Option<[u8; 6]> {
109        self.local_mac
110    }
111
112    /// Set the node's public key for beacon construction.
113    ///
114    /// Must be called before start if announce is enabled.
115    pub fn set_local_pubkey(&mut self, pubkey: XOnlyPublicKey) {
116        self.local_pubkey = Some(pubkey);
117    }
118
119    /// Get a reference to the statistics.
120    pub fn stats(&self) -> &Arc<EthernetStats> {
121        &self.stats
122    }
123
124    /// Start the transport asynchronously.
125    ///
126    /// Creates the AF_PACKET socket, spawns the receive loop, and
127    /// optionally spawns the beacon sender task.
128    pub async fn start_async(&mut self) -> Result<(), TransportError> {
129        if !self.state.can_start() {
130            return Err(TransportError::AlreadyStarted);
131        }
132
133        self.state = TransportState::Starting;
134
135        // Create and bind AF_PACKET socket
136        let raw_socket = PacketSocket::open(&self.config.interface, self.config.ethertype())?;
137
138        // Get local MAC and MTU
139        let local_mac = raw_socket.local_mac()?;
140        let if_mtu = raw_socket.interface_mtu()?;
141
142        // Effective MTU: interface MTU minus 3 bytes for frame header
143        // (1 byte frame type + 2 bytes LE payload length)
144        let effective_mtu = if let Some(configured_mtu) = self.config.mtu {
145            // Config MTU cannot exceed interface MTU - 3
146            configured_mtu.min(if_mtu.saturating_sub(3))
147        } else {
148            if_mtu.saturating_sub(3)
149        };
150        self.effective_mtu = effective_mtu;
151        self.local_mac = Some(local_mac);
152
153        // Set buffer sizes
154        raw_socket.set_recv_buffer_size(self.config.recv_buf_size())?;
155        raw_socket.set_send_buffer_size(self.config.send_buf_size())?;
156
157        // Wrap in async
158        let async_socket = raw_socket.into_async()?;
159        let socket = Arc::new(async_socket);
160        self.socket = Some(socket.clone());
161
162        let recv_task = tokio::spawn(ethernet_receive_loop(EthernetReceiveContext {
163            socket: socket.clone(),
164            transport_id: self.transport_id,
165            packet_tx: self.packet_tx.clone(),
166            mtu: self.effective_mtu,
167            discovery_enabled: self.config.discovery(),
168            discovery_buffer: self.discovery_buffer.clone(),
169            stats: self.stats.clone(),
170            local_mac,
171        }));
172        self.recv_task = Some(recv_task);
173
174        // Spawn beacon sender if announce is enabled
175        if self.config.announce() {
176            if let Some(pubkey) = self.local_pubkey {
177                let beacon_task = tokio::spawn(beacon_sender_loop(EthernetBeaconContext {
178                    socket: socket.clone(),
179                    pubkey,
180                    discovery_scope: self.config.discovery_scope().map(str::to_string),
181                    interval_secs: self.config.beacon_interval_secs(),
182                    stats: self.stats.clone(),
183                    transport_id: self.transport_id,
184                    interface: self.config.interface.clone(),
185                    ethertype: self.config.ethertype(),
186                }));
187                self.beacon_task = Some(beacon_task);
188            } else {
189                warn!(
190                    transport_id = %self.transport_id,
191                    "Announce enabled but no local pubkey set; beacons disabled"
192                );
193            }
194        }
195
196        self.state = TransportState::Up;
197
198        if let Some(ref name) = self.name {
199            info!(
200                name = %name,
201                interface = %self.interface,
202                mac = %format_mac(&local_mac),
203                mtu = effective_mtu,
204                if_mtu = if_mtu,
205                "Ethernet transport started"
206            );
207        } else {
208            info!(
209                interface = %self.interface,
210                mac = %format_mac(&local_mac),
211                mtu = effective_mtu,
212                if_mtu = if_mtu,
213                "Ethernet transport started"
214            );
215        }
216
217        Ok(())
218    }
219
220    /// Stop the transport asynchronously.
221    pub async fn stop_async(&mut self) -> Result<(), TransportError> {
222        if !self.state.is_operational() {
223            return Err(TransportError::NotStarted);
224        }
225
226        // Signal the socket to shut down. On macOS this writes to the
227        // shutdown pipe, waking the reader thread's select() immediately.
228        // On Linux this is a no-op (AsyncFd cancellation handles it).
229        if let Some(ref socket) = self.socket {
230            socket.shutdown();
231        }
232
233        // Abort tasks. On Linux, safe to await since all I/O is
234        // AsyncFd-based and cancellation-safe. On macOS, do NOT await —
235        // on a current_thread runtime the aborted task can't be polled
236        // while we're blocked on the JoinHandle, causing a deadlock.
237        if let Some(task) = self.beacon_task.take() {
238            task.abort();
239            #[cfg(not(target_os = "macos"))]
240            {
241                let _ = task.await;
242            }
243        }
244        if let Some(task) = self.recv_task.take() {
245            task.abort();
246            #[cfg(not(target_os = "macos"))]
247            {
248                let _ = task.await;
249            }
250        }
251
252        // Drop socket
253        self.socket.take();
254        self.local_mac = None;
255
256        self.state = TransportState::Down;
257
258        info!(
259            transport_id = %self.transport_id,
260            interface = %self.interface,
261            "Ethernet transport stopped"
262        );
263
264        Ok(())
265    }
266
267    /// Send a packet asynchronously.
268    ///
269    /// The data is prepended with a FRAME_TYPE_DATA prefix byte before
270    /// transmission.
271    pub async fn send_async(
272        &self,
273        addr: &TransportAddr,
274        data: &[u8],
275    ) -> Result<usize, TransportError> {
276        if !self.state.is_operational() {
277            return Err(TransportError::NotStarted);
278        }
279
280        if data.len() > self.effective_mtu as usize {
281            return Err(TransportError::MtuExceeded {
282                packet_size: data.len(),
283                mtu: self.effective_mtu,
284            });
285        }
286
287        let dest_mac = parse_mac_addr(addr)?;
288        let socket = self.socket.as_ref().ok_or(TransportError::NotStarted)?;
289
290        // Prepend frame type prefix and 2-byte LE payload length.
291        // The length field lets the receiver trim Ethernet minimum-frame padding
292        // (NICs pad frames shorter than 46 bytes payload to 46 bytes with zeros,
293        // which would otherwise corrupt AEAD ciphertext verification).
294        let mut frame = Vec::with_capacity(3 + data.len());
295        frame.push(FRAME_TYPE_DATA);
296        frame.extend_from_slice(&(data.len() as u16).to_le_bytes());
297        frame.extend_from_slice(data);
298
299        let bytes_sent = socket.send_to(&frame, &dest_mac).await?;
300        self.stats.record_send(bytes_sent);
301
302        trace!(
303            transport_id = %self.transport_id,
304            remote_mac = %format_mac(&dest_mac),
305            bytes = bytes_sent,
306            "Ethernet frame sent"
307        );
308
309        // Return the data bytes sent (excluding frame type prefix and length field)
310        Ok(bytes_sent.saturating_sub(3))
311    }
312}
313
314impl Transport for EthernetTransport {
315    fn transport_id(&self) -> TransportId {
316        self.transport_id
317    }
318
319    fn transport_type(&self) -> &TransportType {
320        &TransportType::ETHERNET
321    }
322
323    fn state(&self) -> TransportState {
324        self.state
325    }
326
327    fn mtu(&self) -> u16 {
328        self.effective_mtu
329    }
330
331    fn start(&mut self) -> Result<(), TransportError> {
332        Err(TransportError::NotSupported(
333            "use start_async() for Ethernet transport".into(),
334        ))
335    }
336
337    fn stop(&mut self) -> Result<(), TransportError> {
338        Err(TransportError::NotSupported(
339            "use stop_async() for Ethernet transport".into(),
340        ))
341    }
342
343    fn send(&self, _addr: &TransportAddr, _data: &[u8]) -> Result<(), TransportError> {
344        Err(TransportError::NotSupported(
345            "use send_async() for Ethernet transport".into(),
346        ))
347    }
348
349    fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> {
350        Ok(self.discovery_buffer.take())
351    }
352
353    fn auto_connect(&self) -> bool {
354        self.config.auto_connect()
355    }
356
357    fn accept_connections(&self) -> bool {
358        self.config.accept_connections()
359    }
360}
361
362// ============================================================================
363// Receive Loop
364// ============================================================================
365
366struct EthernetReceiveContext {
367    socket: Arc<AsyncPacketSocket>,
368    transport_id: TransportId,
369    packet_tx: PacketTx,
370    mtu: u16,
371    discovery_enabled: bool,
372    discovery_buffer: Arc<DiscoveryBuffer>,
373    stats: Arc<EthernetStats>,
374    local_mac: [u8; 6],
375}
376
377/// Ethernet receive loop — runs as a spawned task.
378async fn ethernet_receive_loop(ctx: EthernetReceiveContext) {
379    let EthernetReceiveContext {
380        socket,
381        transport_id,
382        packet_tx,
383        mtu,
384        discovery_enabled,
385        discovery_buffer,
386        stats,
387        local_mac,
388    } = ctx;
389
390    // Buffer with headroom: frame type prefix + MTU + some extra
391    let mut buf = vec![0u8; mtu as usize + 100];
392
393    debug!(transport_id = %transport_id, "Ethernet receive loop starting");
394
395    loop {
396        match socket.recv_from(&mut buf).await {
397            Ok((len, src_mac)) => {
398                if len == 0 {
399                    continue;
400                }
401                if src_mac == local_mac {
402                    trace!(
403                        transport_id = %transport_id,
404                        local_mac = %format_mac(&local_mac),
405                        "Ignoring self-echoed Ethernet frame"
406                    );
407                    continue;
408                }
409
410                stats.record_recv(len);
411
412                let frame_type = buf[0];
413                match frame_type {
414                    FRAME_TYPE_DATA => {
415                        // Data frame: [type:1][length:2 LE][payload:N]
416                        // Use the length field to trim Ethernet minimum-frame padding.
417                        if len < 3 {
418                            trace!("Data frame too short ({len} bytes), ignoring");
419                            continue;
420                        }
421                        let payload_len = u16::from_le_bytes([buf[1], buf[2]]) as usize;
422                        if payload_len > len - 3 {
423                            trace!(
424                                "Data frame length field ({payload_len}) exceeds \
425                                 available bytes ({}), ignoring",
426                                len - 3
427                            );
428                            continue;
429                        }
430                        let data = buf[3..3 + payload_len].to_vec();
431                        let addr = TransportAddr::from_bytes(&src_mac);
432                        let packet = ReceivedPacket::with_timestamp(
433                            transport_id,
434                            addr,
435                            PacketBuffer::new(data),
436                            crate::time::now_ms(),
437                        );
438
439                        trace!(
440                            transport_id = %transport_id,
441                            remote_mac = %format_mac(&src_mac),
442                            bytes = payload_len,
443                            "Ethernet data frame received"
444                        );
445
446                        if packet_tx.send(packet).is_err() {
447                            debug!(
448                                transport_id = %transport_id,
449                                "Packet channel closed, stopping receive loop"
450                            );
451                            break;
452                        }
453                    }
454                    FRAME_TYPE_BEACON => {
455                        stats.record_beacon_recv();
456
457                        if discovery_enabled && let Some(beacon) = parse_beacon_record(&buf[..len])
458                        {
459                            discovery_buffer.add_peer(src_mac, beacon);
460                            trace!(
461                                transport_id = %transport_id,
462                                remote_mac = %format_mac(&src_mac),
463                                "Discovery beacon received"
464                            );
465                        }
466                    }
467                    _ => {
468                        // Unknown frame type, ignore
469                        trace!(
470                            transport_id = %transport_id,
471                            frame_type = frame_type,
472                            "Unknown frame type, dropping"
473                        );
474                    }
475                }
476            }
477            Err(e) => {
478                stats.record_recv_error();
479                warn!(
480                    transport_id = %transport_id,
481                    error = %e,
482                    "Ethernet receive error"
483                );
484            }
485        }
486    }
487
488    debug!(transport_id = %transport_id, "Ethernet receive loop stopped");
489}
490
491// ============================================================================
492// Beacon Sender
493// ============================================================================
494
495/// Periodic beacon sender loop.
496///
497/// Detects stale AF_PACKET sockets (ENXIO / os error 6) that occur when
498/// the underlying veth interface is destroyed and recreated (e.g., during
499/// node churn in chaos tests). After `REOPEN_THRESHOLD` consecutive send
500/// failures, attempts to open a fresh socket on the same interface.
501struct EthernetBeaconContext {
502    socket: Arc<AsyncPacketSocket>,
503    pubkey: XOnlyPublicKey,
504    discovery_scope: Option<String>,
505    interval_secs: u64,
506    stats: Arc<EthernetStats>,
507    transport_id: TransportId,
508    interface: String,
509    ethertype: u16,
510}
511
512async fn beacon_sender_loop(ctx: EthernetBeaconContext) {
513    let EthernetBeaconContext {
514        mut socket,
515        pubkey,
516        discovery_scope,
517        interval_secs,
518        stats,
519        transport_id,
520        interface,
521        ethertype,
522    } = ctx;
523
524    /// Number of consecutive ENXIO errors before attempting socket reopen.
525    const REOPEN_THRESHOLD: u32 = 3;
526
527    let beacon = build_scoped_beacon(&pubkey, discovery_scope.as_deref());
528    let interval = tokio::time::Duration::from_secs(interval_secs);
529
530    debug!(
531        transport_id = %transport_id,
532        interval_secs,
533        "Beacon sender starting"
534    );
535
536    // Send an initial beacon immediately at startup
537    if let Err(e) = socket.send_to(&beacon, &ETHERNET_BROADCAST).await {
538        warn!(
539            transport_id = %transport_id,
540            error = %e,
541            "Failed to send initial beacon"
542        );
543    } else {
544        stats.record_beacon_sent();
545    }
546
547    let mut interval_timer = tokio::time::interval(interval);
548    interval_timer.tick().await; // consume the immediate first tick
549    let mut consecutive_errors: u32 = 0;
550
551    loop {
552        interval_timer.tick().await;
553
554        match socket.send_to(&beacon, &ETHERNET_BROADCAST).await {
555            Ok(_) => {
556                if consecutive_errors > 0 {
557                    debug!(
558                        transport_id = %transport_id,
559                        "Beacon send recovered after {} errors", consecutive_errors,
560                    );
561                }
562                consecutive_errors = 0;
563                stats.record_beacon_sent();
564                trace!(
565                    transport_id = %transport_id,
566                    "Beacon sent"
567                );
568            }
569            Err(e) => {
570                consecutive_errors += 1;
571                stats.record_send_error();
572
573                let is_enxio = format!("{e}").contains("os error 6");
574
575                // Log only the first error in a streak to avoid log spam
576                if consecutive_errors == 1 {
577                    warn!(
578                        transport_id = %transport_id,
579                        error = %e,
580                        "Failed to send beacon"
581                    );
582                }
583
584                if is_enxio && consecutive_errors >= REOPEN_THRESHOLD {
585                    info!(
586                        transport_id = %transport_id,
587                        consecutive_errors,
588                        interface = %interface,
589                        "Stale veth detected (ENXIO), attempting socket reopen"
590                    );
591                    match reopen_beacon_socket(&interface, ethertype) {
592                        Ok(new_socket) => {
593                            socket = Arc::new(new_socket);
594                            consecutive_errors = 0;
595                            info!(
596                                transport_id = %transport_id,
597                                interface = %interface,
598                                "Beacon socket reopened successfully"
599                            );
600                        }
601                        Err(e) => {
602                            warn!(
603                                transport_id = %transport_id,
604                                error = %e,
605                                interface = %interface,
606                                "Failed to reopen beacon socket, will retry"
607                            );
608                        }
609                    }
610                }
611            }
612        }
613    }
614}
615
616/// Attempt to open a fresh AF_PACKET socket for beacon sending.
617///
618/// This is called when the beacon sender detects that the underlying veth
619/// has been recreated and the old socket FD is stale (ENXIO).
620fn reopen_beacon_socket(
621    interface: &str,
622    ethertype: u16,
623) -> Result<AsyncPacketSocket, TransportError> {
624    let raw_socket = PacketSocket::open(interface, ethertype)?;
625    raw_socket.into_async()
626}
627
628// ============================================================================
629// MAC Address Helpers
630// ============================================================================
631
632/// Parse a TransportAddr as a 6-byte MAC address.
633fn parse_mac_addr(addr: &TransportAddr) -> Result<[u8; 6], TransportError> {
634    let bytes = addr.as_bytes();
635    if bytes.len() != 6 {
636        return Err(TransportError::InvalidAddress(format!(
637            "expected 6-byte MAC, got {} bytes",
638            bytes.len()
639        )));
640    }
641    if bytes == [0, 0, 0, 0, 0, 0] {
642        return Err(TransportError::InvalidAddress(
643            "destination MAC is all zeros".into(),
644        ));
645    }
646    let mut mac = [0u8; 6];
647    mac.copy_from_slice(bytes);
648    Ok(mac)
649}
650
651/// Format a MAC address as colon-separated hex for display.
652pub fn format_mac(mac: &[u8; 6]) -> String {
653    format!(
654        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
655        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
656    )
657}
658
659/// Parse a colon-separated MAC string (e.g., "aa:bb:cc:dd:ee:ff") into bytes.
660pub fn parse_mac_string(s: &str) -> Result<[u8; 6], TransportError> {
661    let parts: Vec<&str> = s.split(':').collect();
662    if parts.len() != 6 {
663        return Err(TransportError::InvalidAddress(format!(
664            "invalid MAC format: expected 6 colon-separated hex bytes, got '{}'",
665            s
666        )));
667    }
668    let mut mac = [0u8; 6];
669    for (i, part) in parts.iter().enumerate() {
670        mac[i] = u8::from_str_radix(part, 16).map_err(|_| {
671            TransportError::InvalidAddress(format!("invalid hex byte '{}' in MAC address", part))
672        })?;
673    }
674    Ok(mac)
675}
676
677// ============================================================================
678// Tests
679// ============================================================================
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn test_parse_mac_addr_valid() {
687        let addr = TransportAddr::from_bytes(&[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]);
688        let mac = parse_mac_addr(&addr).unwrap();
689        assert_eq!(mac, [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]);
690    }
691
692    #[test]
693    fn test_parse_mac_addr_wrong_length() {
694        let addr = TransportAddr::from_bytes(&[0xaa, 0xbb, 0xcc]);
695        assert!(parse_mac_addr(&addr).is_err());
696
697        let addr = TransportAddr::from_string("192.168.1.1:2121");
698        assert!(parse_mac_addr(&addr).is_err());
699    }
700
701    #[test]
702    fn test_parse_mac_addr_all_zeros() {
703        let addr = TransportAddr::from_bytes(&[0, 0, 0, 0, 0, 0]);
704        assert!(parse_mac_addr(&addr).is_err());
705    }
706
707    #[test]
708    fn test_format_mac() {
709        let mac = [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff];
710        assert_eq!(format_mac(&mac), "aa:bb:cc:dd:ee:ff");
711    }
712
713    #[test]
714    fn test_format_mac_leading_zeros() {
715        let mac = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06];
716        assert_eq!(format_mac(&mac), "01:02:03:04:05:06");
717    }
718
719    #[test]
720    fn test_parse_mac_string_valid() {
721        let mac = parse_mac_string("aa:bb:cc:dd:ee:ff").unwrap();
722        assert_eq!(mac, [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]);
723    }
724
725    #[test]
726    fn test_parse_mac_string_uppercase() {
727        let mac = parse_mac_string("AA:BB:CC:DD:EE:FF").unwrap();
728        assert_eq!(mac, [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]);
729    }
730
731    #[test]
732    fn test_parse_mac_string_invalid() {
733        assert!(parse_mac_string("aa:bb:cc").is_err());
734        assert!(parse_mac_string("not:a:mac:at:all:x").is_err());
735        assert!(parse_mac_string("").is_err());
736        assert!(parse_mac_string("aa-bb-cc-dd-ee-ff").is_err());
737    }
738
739    #[test]
740    fn test_frame_type_data_prefix() {
741        // Verify data frames have type prefix + 2-byte LE length + payload
742        let data = vec![1, 2, 3, 4];
743        let mut frame = Vec::with_capacity(3 + data.len());
744        frame.push(FRAME_TYPE_DATA);
745        frame.extend_from_slice(&(data.len() as u16).to_le_bytes());
746        frame.extend_from_slice(&data);
747
748        assert_eq!(frame[0], 0x00); // frame type
749        assert_eq!(u16::from_le_bytes([frame[1], frame[2]]), 4); // length
750        assert_eq!(&frame[3..], &[1, 2, 3, 4]); // payload
751    }
752
753    #[test]
754    fn test_data_frame_padding_trimmed() {
755        // Simulate Ethernet minimum-frame padding: a 4-byte payload produces
756        // a 7-byte frame (type + len + payload), padded to 46 bytes by NIC.
757        let payload = vec![0xAA, 0xBB, 0xCC, 0xDD];
758        let payload_len = payload.len() as u16;
759
760        // Build frame as sender would
761        let mut frame = Vec::with_capacity(3 + payload.len());
762        frame.push(FRAME_TYPE_DATA);
763        frame.extend_from_slice(&payload_len.to_le_bytes());
764        frame.extend_from_slice(&payload);
765
766        // Simulate NIC padding to 46 bytes
767        frame.resize(46, 0x00);
768
769        // Receiver extracts using length field
770        let recv_len = u16::from_le_bytes([frame[1], frame[2]]) as usize;
771        let extracted = &frame[3..3 + recv_len];
772        assert_eq!(extracted, &[0xAA, 0xBB, 0xCC, 0xDD]);
773    }
774
775    #[test]
776    fn test_beacon_size() {
777        assert_eq!(discovery::BEACON_SIZE, 34);
778    }
779}