Skip to main content

netget/server/stp/
codec.rs

1//! Pure BPDU codec — IEEE 802.1D-2004 (STP) and 802.1w (RSTP).
2//!
3//! Nothing in this file touches a socket, a capture handle, the LLM or `AppState`. It is a
4//! total function from structured values to bytes and back, which is the only part of this
5//! protocol that can be proved correct in this environment: the raw 802.3 transport in
6//! `mod.rs` needs `CAP_NET_RAW`/`/dev/bpf*` and has never been executed. See
7//! `src/server/stp/CLAUDE.md` for exactly what is proved and what is not.
8//!
9//! # Wire layout
10//!
11//! A BPDU is carried in an **802.3 length-encapsulated** frame with an 802.2 LLC header,
12//! *not* in an EtherType-II frame:
13//!
14//! ```text
15//!  0.. 6  destination MAC   01:80:C2:00:00:00 (the Bridge Group Address)
16//!  6..12  source MAC        the transmitting bridge port
17//! 12..14  length            octets of MAC client data: 3 (LLC) + BPDU length
18//! 14..17  LLC               DSAP 0x42, SSAP 0x42, control 0x03 (unnumbered information)
19//! 17..    BPDU              35 bytes (config), 36 (RST), or 4 (TCN)
20//! ```
21//!
22//! Configuration BPDU body (802.1D-2004 §9.3.1), offsets relative to the start of the BPDU:
23//!
24//! ```text
25//!  0.. 2  protocol identifier   always 0x0000
26//!  2      protocol version      0 = STP, 2 = RSTP
27//!  3      BPDU type             0x00 config, 0x02 RST, 0x80 TCN
28//!  4      flags                 see BpduFlags
29//!  5..13  root identifier       priority(4b) | system ID extension(12b) | MAC(6)
30//! 13..17  root path cost
31//! 17..25  bridge identifier     same packing as the root identifier
32//! 25..27  port identifier       priority(4b) | port number(12b)
33//! 27..29  message age           1/256 s
34//! 29..31  max age               1/256 s
35//! 31..33  hello time            1/256 s
36//! 33..35  forward delay         1/256 s
37//! 35      version 1 length      RST BPDUs only, always 0x00
38//! ```
39//!
40//! # Two things this file exists to get right
41//!
42//! **Timers are in 1/256-second units, not seconds.** The default values every capture shows
43//! are max age 20 s = `0x1400`, hello time 2 s = `0x0200`, forward delay 15 s = `0x0F00`. An
44//! implementation that writes seconds directly produces a BPDU claiming a 20/256-second max
45//! age, which a real bridge will act on. `tests/server/stp/codec_test.rs` asserts these byte
46//! pairs against literals.
47//!
48//! **The 16-bit priority field is two fields.** Since 802.1t / 802.1Q the high 4 bits are the
49//! bridge priority and the low 12 bits are the *system ID extension* (in practice the VLAN
50//! id). So priority is only expressible in steps of 4096, and `0x8001` is priority 32768 on
51//! VLAN 1 — not "priority 32769". Both halves are surfaced separately by [`BridgeId`] so the
52//! model never has to pack them itself.
53
54use anyhow::{anyhow, bail, Result};
55
56/// The Bridge Group Address every BPDU is sent to (802.1D-2004 Table 7-9).
57pub const STP_MULTICAST_MAC: [u8; 6] = [0x01, 0x80, 0xC2, 0x00, 0x00, 0x00];
58
59/// 802.2 LLC header for the Spanning Tree Protocol: DSAP/SSAP 0x42, UI control 0x03.
60pub const LLC_DSAP: u8 = 0x42;
61pub const LLC_SSAP: u8 = 0x42;
62pub const LLC_CONTROL: u8 = 0x03;
63pub const LLC_HEADER_LEN: usize = 3;
64
65/// 802.3 MAC header: destination(6) + source(6) + length(2).
66pub const ETHERNET_HEADER_LEN: usize = 14;
67
68/// Minimum 802.3 frame excluding the FCS. Shorter frames are padded on transmit.
69pub const MIN_ETHERNET_FRAME_LEN: usize = 60;
70
71/// The largest value the 802.3 length field can carry **as a length**.
72///
73/// IEEE 802.3 reserves every value from `0x0600` (1536) upwards for EtherType, so a frame whose
74/// length field reaches there is read as Ethernet II and its LLC payload is never looked at.
75/// 1500 is the largest value that is unambiguously a length, and both directions enforce it.
76pub const MAX_8023_LENGTH: usize = 1500;
77
78pub const PROTOCOL_ID: u16 = 0x0000;
79
80pub const VERSION_STP: u8 = 0;
81pub const VERSION_RSTP: u8 = 2;
82
83pub const BPDU_TYPE_CONFIG: u8 = 0x00;
84pub const BPDU_TYPE_RST: u8 = 0x02;
85pub const BPDU_TYPE_TCN: u8 = 0x80;
86
87pub const CONFIG_BPDU_LEN: usize = 35;
88pub const RST_BPDU_LEN: usize = 36;
89pub const TCN_BPDU_LEN: usize = 4;
90
91/// Timer fields are expressed in units of 1/256 second.
92pub const TIMER_TICKS_PER_SECOND: f64 = 256.0;
93
94/// Bridge priority occupies the top 4 bits of a 16-bit field, so it moves in steps of 4096.
95pub const BRIDGE_PRIORITY_STEP: u16 = 4096;
96/// The largest expressible bridge priority (15 * 4096).
97pub const BRIDGE_PRIORITY_MAX: u16 = 61440;
98/// The largest expressible system ID extension (12 bits).
99pub const SYSTEM_ID_EXTENSION_MAX: u16 = 4095;
100
101/// Port priority occupies the top 4 bits of the 16-bit port identifier.
102pub const PORT_PRIORITY_STEP: u8 = 16;
103/// The largest expressible port priority (15 * 16).
104pub const PORT_PRIORITY_MAX: u8 = 240;
105/// The largest expressible port number (12 bits).
106pub const PORT_NUMBER_MAX: u16 = 4095;
107
108// ---------------------------------------------------------------------------
109// MAC helpers
110// ---------------------------------------------------------------------------
111
112/// Parse `aa:bb:cc:dd:ee:ff` (or the `-` separated form) into six octets.
113pub fn parse_mac(s: &str) -> Result<[u8; 6]> {
114    let parts: Vec<&str> = s.split([':', '-']).collect();
115    if parts.len() != 6 {
116        bail!("MAC address must have 6 octets separated by ':' or '-', got '{s}'");
117    }
118    let mut out = [0u8; 6];
119    for (i, part) in parts.iter().enumerate() {
120        out[i] = u8::from_str_radix(part, 16)
121            .map_err(|_| anyhow!("invalid hex octet '{part}' in MAC address '{s}'"))?;
122    }
123    Ok(out)
124}
125
126/// Render six octets as `aa:bb:cc:dd:ee:ff`.
127pub fn format_mac(mac: &[u8; 6]) -> String {
128    format!(
129        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
130        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
131    )
132}
133
134// ---------------------------------------------------------------------------
135// Timers
136// ---------------------------------------------------------------------------
137
138/// Seconds → the 1/256-second unit the wire uses.
139///
140/// This is the conversion an implementation gets wrong by omitting it, so it lives in one
141/// place and is asserted against literal bytes.
142pub fn seconds_to_ticks(seconds: f64) -> Result<u16> {
143    if !seconds.is_finite() || seconds < 0.0 {
144        bail!("timer value must be a finite, non-negative number of seconds, got {seconds}");
145    }
146    let ticks = (seconds * TIMER_TICKS_PER_SECOND).round();
147    if ticks > u16::MAX as f64 {
148        bail!(
149            "timer value {seconds}s does not fit the 16-bit 1/256s field (maximum {:.4}s)",
150            u16::MAX as f64 / TIMER_TICKS_PER_SECOND
151        );
152    }
153    Ok(ticks as u16)
154}
155
156/// The 1/256-second unit the wire uses → seconds.
157pub fn ticks_to_seconds(ticks: u16) -> f64 {
158    ticks as f64 / TIMER_TICKS_PER_SECOND
159}
160
161// ---------------------------------------------------------------------------
162// Flags
163// ---------------------------------------------------------------------------
164
165/// RSTP port role, flags bits 2-3 (802.1w §9.3.3).
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167pub enum PortRole {
168    /// 0b00 — sent by an 802.1D bridge, or a role not yet assigned.
169    #[default]
170    Unknown,
171    /// 0b01 — alternate or backup.
172    Alternate,
173    /// 0b10 — root port.
174    Root,
175    /// 0b11 — designated port. This is the role a bridge claims when it believes it owns the
176    /// segment, and the one a rogue root bridge asserts.
177    Designated,
178}
179
180impl PortRole {
181    pub fn as_str(self) -> &'static str {
182        match self {
183            PortRole::Unknown => "unknown",
184            PortRole::Alternate => "alternate",
185            PortRole::Root => "root",
186            PortRole::Designated => "designated",
187        }
188    }
189
190    /// Parse the name used in events and actions. `backup` is accepted as a synonym for
191    /// `alternate` because they share one encoding.
192    pub fn from_name(name: &str) -> Result<Self> {
193        match name.trim().to_ascii_lowercase().as_str() {
194            "unknown" => Ok(PortRole::Unknown),
195            "alternate" | "backup" => Ok(PortRole::Alternate),
196            "root" => Ok(PortRole::Root),
197            "designated" => Ok(PortRole::Designated),
198            other => bail!(
199                "unknown port_role '{other}' (expected unknown, alternate, backup, root or designated)"
200            ),
201        }
202    }
203
204    fn to_bits(self) -> u8 {
205        match self {
206            PortRole::Unknown => 0b00,
207            PortRole::Alternate => 0b01,
208            PortRole::Root => 0b10,
209            PortRole::Designated => 0b11,
210        }
211    }
212
213    fn from_bits(bits: u8) -> Self {
214        match bits & 0b11 {
215            0b01 => PortRole::Alternate,
216            0b10 => PortRole::Root,
217            0b11 => PortRole::Designated,
218            _ => PortRole::Unknown,
219        }
220    }
221}
222
223/// The BPDU flags octet, as structured booleans plus the port role.
224///
225/// 802.1D uses only bit 0 (topology change) and bit 7 (topology change acknowledgement); 802.1w
226/// gives every other bit a meaning. Decoding an 802.1D BPDU therefore yields
227/// `port_role: Unknown` and the four RSTP booleans false, which is exactly what the wire says.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
229pub struct BpduFlags {
230    /// Bit 0 (0x01) — topology change.
231    pub topology_change: bool,
232    /// Bit 1 (0x02) — proposal (RSTP).
233    pub proposal: bool,
234    /// Bits 2-3 (0x0C) — port role (RSTP).
235    pub port_role: PortRole,
236    /// Bit 4 (0x10) — learning (RSTP).
237    pub learning: bool,
238    /// Bit 5 (0x20) — forwarding (RSTP).
239    pub forwarding: bool,
240    /// Bit 6 (0x40) — agreement (RSTP).
241    pub agreement: bool,
242    /// Bit 7 (0x80) — topology change acknowledgement.
243    pub topology_change_ack: bool,
244}
245
246impl BpduFlags {
247    pub fn to_byte(self) -> u8 {
248        let mut byte = 0u8;
249        if self.topology_change {
250            byte |= 0x01;
251        }
252        if self.proposal {
253            byte |= 0x02;
254        }
255        byte |= self.port_role.to_bits() << 2;
256        if self.learning {
257            byte |= 0x10;
258        }
259        if self.forwarding {
260            byte |= 0x20;
261        }
262        if self.agreement {
263            byte |= 0x40;
264        }
265        if self.topology_change_ack {
266            byte |= 0x80;
267        }
268        byte
269    }
270
271    pub fn from_byte(byte: u8) -> Self {
272        Self {
273            topology_change: byte & 0x01 != 0,
274            proposal: byte & 0x02 != 0,
275            port_role: PortRole::from_bits(byte >> 2),
276            learning: byte & 0x10 != 0,
277            forwarding: byte & 0x20 != 0,
278            agreement: byte & 0x40 != 0,
279            topology_change_ack: byte & 0x80 != 0,
280        }
281    }
282}
283
284// ---------------------------------------------------------------------------
285// Bridge and port identifiers
286// ---------------------------------------------------------------------------
287
288/// A bridge identifier: 4-bit priority, 12-bit system ID extension, 6-byte MAC.
289///
290/// The two halves of the leading 16-bit field are kept apart deliberately. A single opaque
291/// number is what makes people write `priority: 32769` when they mean "priority 32768 on
292/// VLAN 1", and the lower bits are not priority at all.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub struct BridgeId {
295    /// 0..=61440 in steps of 4096. **Lower wins the root election.**
296    pub priority: u16,
297    /// 0..=4095. The VLAN id in per-VLAN spanning tree; 0 for a single instance.
298    pub system_id_extension: u16,
299    pub mac: [u8; 6],
300}
301
302impl BridgeId {
303    /// Construct with validation. Rejects a priority that is not a multiple of 4096, because
304    /// there is no way to put one on the wire — the low bits belong to the VLAN.
305    pub fn new(priority: u16, system_id_extension: u16, mac: [u8; 6]) -> Result<Self> {
306        if priority % BRIDGE_PRIORITY_STEP != 0 {
307            bail!(
308                "bridge priority must be a multiple of {} (0, 4096, 8192, … {}); got {}. The \
309                 low 12 bits of the field are the system ID extension (VLAN), not priority.",
310                BRIDGE_PRIORITY_STEP,
311                BRIDGE_PRIORITY_MAX,
312                priority
313            );
314        }
315        if priority > BRIDGE_PRIORITY_MAX {
316            bail!(
317                "bridge priority must be at most {}, got {}",
318                BRIDGE_PRIORITY_MAX,
319                priority
320            );
321        }
322        if system_id_extension > SYSTEM_ID_EXTENSION_MAX {
323            bail!(
324                "system_id_extension must be at most {} (12 bits), got {}",
325                SYSTEM_ID_EXTENSION_MAX,
326                system_id_extension
327            );
328        }
329        Ok(Self {
330            priority,
331            system_id_extension,
332            mac,
333        })
334    }
335
336    pub fn encode(&self) -> [u8; 8] {
337        let packed = (self.priority & 0xF000) | (self.system_id_extension & 0x0FFF);
338        let mut out = [0u8; 8];
339        out[0..2].copy_from_slice(&packed.to_be_bytes());
340        out[2..8].copy_from_slice(&self.mac);
341        out
342    }
343
344    /// Decode eight octets. Never fails on a well-sized slice: every 16-bit value splits into
345    /// a valid priority and a valid extension, so a peer cannot send an "invalid" identifier.
346    pub fn decode(bytes: &[u8]) -> Result<Self> {
347        if bytes.len() < 8 {
348            bail!("bridge identifier needs 8 octets, got {}", bytes.len());
349        }
350        let packed = u16::from_be_bytes([bytes[0], bytes[1]]);
351        let mut mac = [0u8; 6];
352        mac.copy_from_slice(&bytes[2..8]);
353        Ok(Self {
354            priority: packed & 0xF000,
355            system_id_extension: packed & 0x0FFF,
356            mac,
357        })
358    }
359
360    pub fn mac_string(&self) -> String {
361        format_mac(&self.mac)
362    }
363}
364
365/// A port identifier: 4-bit priority, 12-bit port number.
366///
367/// 802.1D-1998 split this octet-for-octet (8-bit priority, 8-bit port number); 802.1t
368/// re-split it 4/12 so more than 255 ports are addressable. The two agree byte-for-byte for
369/// the common values — the canonical `0x8001` is priority 128, port 1 under either reading —
370/// and this codec implements the 802.1t/802.1D-2004 split.
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub struct PortId {
373    /// 0..=240 in steps of 16.
374    pub priority: u8,
375    /// 0..=4095.
376    pub number: u16,
377}
378
379impl PortId {
380    pub fn new(priority: u8, number: u16) -> Result<Self> {
381        if priority % PORT_PRIORITY_STEP != 0 {
382            bail!(
383                "port priority must be a multiple of {} (0, 16, 32, … {}); got {}. The low 12 \
384                 bits of the field are the port number, not priority.",
385                PORT_PRIORITY_STEP,
386                PORT_PRIORITY_MAX,
387                priority
388            );
389        }
390        if number > PORT_NUMBER_MAX {
391            bail!(
392                "port number must be at most {} (12 bits), got {}",
393                PORT_NUMBER_MAX,
394                number
395            );
396        }
397        Ok(Self { priority, number })
398    }
399
400    pub fn encode(&self) -> [u8; 2] {
401        let packed = (((self.priority & 0xF0) as u16) << 8) | (self.number & 0x0FFF);
402        packed.to_be_bytes()
403    }
404
405    pub fn decode(bytes: &[u8]) -> Result<Self> {
406        if bytes.len() < 2 {
407            bail!("port identifier needs 2 octets, got {}", bytes.len());
408        }
409        let packed = u16::from_be_bytes([bytes[0], bytes[1]]);
410        Ok(Self {
411            priority: ((packed >> 8) as u8) & 0xF0,
412            number: packed & 0x0FFF,
413        })
414    }
415}
416
417// ---------------------------------------------------------------------------
418// BPDUs
419// ---------------------------------------------------------------------------
420
421/// A Configuration BPDU (802.1D type 0x00) or a Rapid Spanning Tree BPDU (802.1w type 0x02).
422///
423/// The two differ only in the version/type octets and a trailing `version 1 length` octet, so
424/// they share one struct; [`ConfigBpdu::is_rstp`] reports which is on the wire.
425#[derive(Debug, Clone, PartialEq)]
426pub struct ConfigBpdu {
427    /// 0 = STP, 2 = RSTP. Values above 2 are accepted on decode (MSTP sends 3) and reported
428    /// verbatim rather than being normalised away.
429    pub version: u8,
430    /// 0x00 (config) or 0x02 (RST).
431    pub bpdu_type: u8,
432    pub flags: BpduFlags,
433    pub root: BridgeId,
434    pub root_path_cost: u32,
435    pub bridge: BridgeId,
436    pub port: PortId,
437    pub message_age_seconds: f64,
438    pub max_age_seconds: f64,
439    pub hello_time_seconds: f64,
440    pub forward_delay_seconds: f64,
441}
442
443impl ConfigBpdu {
444    /// True when this is a Rapid Spanning Tree BPDU rather than an 802.1D configuration BPDU.
445    pub fn is_rstp(&self) -> bool {
446        self.bpdu_type == BPDU_TYPE_RST
447    }
448
449    /// Serialise the BPDU body — 35 octets for a configuration BPDU, 36 for an RST BPDU.
450    pub fn encode(&self) -> Result<Vec<u8>> {
451        if self.bpdu_type != BPDU_TYPE_CONFIG && self.bpdu_type != BPDU_TYPE_RST {
452            bail!(
453                "BPDU type must be 0x00 (configuration) or 0x02 (RST) here; 0x{:02x} is not a \
454                 configuration-shaped BPDU",
455                self.bpdu_type
456            );
457        }
458        // Re-validate the identifiers: they are public fields, so a caller can build a
459        // ConfigBpdu without going through the checked constructors.
460        let root = BridgeId::new(
461            self.root.priority,
462            self.root.system_id_extension,
463            self.root.mac,
464        )?;
465        let bridge = BridgeId::new(
466            self.bridge.priority,
467            self.bridge.system_id_extension,
468            self.bridge.mac,
469        )?;
470        let port = PortId::new(self.port.priority, self.port.number)?;
471
472        let mut out = Vec::with_capacity(RST_BPDU_LEN);
473        out.extend_from_slice(&PROTOCOL_ID.to_be_bytes());
474        out.push(self.version);
475        out.push(self.bpdu_type);
476        out.push(self.flags.to_byte());
477        out.extend_from_slice(&root.encode());
478        out.extend_from_slice(&self.root_path_cost.to_be_bytes());
479        out.extend_from_slice(&bridge.encode());
480        out.extend_from_slice(&port.encode());
481        out.extend_from_slice(&seconds_to_ticks(self.message_age_seconds)?.to_be_bytes());
482        out.extend_from_slice(&seconds_to_ticks(self.max_age_seconds)?.to_be_bytes());
483        out.extend_from_slice(&seconds_to_ticks(self.hello_time_seconds)?.to_be_bytes());
484        out.extend_from_slice(&seconds_to_ticks(self.forward_delay_seconds)?.to_be_bytes());
485        debug_assert_eq!(out.len(), CONFIG_BPDU_LEN);
486
487        if self.bpdu_type == BPDU_TYPE_RST {
488            // 802.1w §9.3.3: "Version 1 Length", always zero, present only on RST BPDUs.
489            out.push(0x00);
490        }
491        Ok(out)
492    }
493
494    /// Parse a configuration or RST BPDU body.
495    pub fn decode(body: &[u8]) -> Result<Self> {
496        if body.len() < CONFIG_BPDU_LEN {
497            bail!(
498                "configuration BPDU needs at least {} octets, got {}",
499                CONFIG_BPDU_LEN,
500                body.len()
501            );
502        }
503        let protocol_id = u16::from_be_bytes([body[0], body[1]]);
504        if protocol_id != PROTOCOL_ID {
505            bail!("protocol identifier must be 0x0000, got 0x{protocol_id:04x}");
506        }
507        Ok(Self {
508            version: body[2],
509            bpdu_type: body[3],
510            flags: BpduFlags::from_byte(body[4]),
511            root: BridgeId::decode(&body[5..13])?,
512            root_path_cost: u32::from_be_bytes([body[13], body[14], body[15], body[16]]),
513            bridge: BridgeId::decode(&body[17..25])?,
514            port: PortId::decode(&body[25..27])?,
515            message_age_seconds: ticks_to_seconds(u16::from_be_bytes([body[27], body[28]])),
516            max_age_seconds: ticks_to_seconds(u16::from_be_bytes([body[29], body[30]])),
517            hello_time_seconds: ticks_to_seconds(u16::from_be_bytes([body[31], body[32]])),
518            forward_delay_seconds: ticks_to_seconds(u16::from_be_bytes([body[33], body[34]])),
519        })
520    }
521}
522
523/// Any BPDU this codec understands.
524#[derive(Debug, Clone, PartialEq)]
525pub enum Bpdu {
526    /// Configuration (0x00) or RST (0x02).
527    Config(ConfigBpdu),
528    /// Topology Change Notification (0x80). Carries no fields at all — the four octets are
529    /// protocol id, version and type.
530    TopologyChangeNotification,
531}
532
533impl Bpdu {
534    pub fn decode(body: &[u8]) -> Result<Self> {
535        if body.len() < TCN_BPDU_LEN {
536            bail!(
537                "BPDU needs at least {} octets, got {}",
538                TCN_BPDU_LEN,
539                body.len()
540            );
541        }
542        let protocol_id = u16::from_be_bytes([body[0], body[1]]);
543        if protocol_id != PROTOCOL_ID {
544            bail!("protocol identifier must be 0x0000, got 0x{protocol_id:04x}");
545        }
546        match body[3] {
547            BPDU_TYPE_TCN => Ok(Bpdu::TopologyChangeNotification),
548            BPDU_TYPE_CONFIG | BPDU_TYPE_RST => Ok(Bpdu::Config(ConfigBpdu::decode(body)?)),
549            other => bail!(
550                "unknown BPDU type 0x{other:02x} (expected 0x00 configuration, 0x02 RST or \
551                 0x80 topology change notification)"
552            ),
553        }
554    }
555
556    pub fn encode(&self) -> Result<Vec<u8>> {
557        match self {
558            Bpdu::Config(config) => config.encode(),
559            Bpdu::TopologyChangeNotification => Ok(encode_tcn_bpdu()),
560        }
561    }
562}
563
564/// The complete four-octet Topology Change Notification BPDU.
565pub fn encode_tcn_bpdu() -> Vec<u8> {
566    vec![0x00, 0x00, VERSION_STP, BPDU_TYPE_TCN]
567}
568
569// ---------------------------------------------------------------------------
570// 802.3 framing
571// ---------------------------------------------------------------------------
572
573/// A decoded 802.3 + LLC frame with the BPDU body extracted.
574#[derive(Debug, Clone, PartialEq, Eq)]
575pub struct Frame8023 {
576    pub destination: [u8; 6],
577    pub source: [u8; 6],
578    /// The BPDU body, with the LLC header removed and any Ethernet padding trimmed off using
579    /// the 802.3 length field.
580    pub payload: Vec<u8>,
581}
582
583/// Wrap a BPDU body in an 802.3 + LLC frame, padded to the 60-octet minimum.
584///
585/// The length field carries the MAC **client data** length — LLC header plus BPDU — and
586/// deliberately does *not* include the padding. A configuration BPDU therefore always shows
587/// `00 26` (38) and an RST BPDU `00 27` (39).
588///
589/// Refuses a body that would not fit the length field *as a length*. Every BPDU this codec
590/// produces is 4, 35 or 36 octets, so no caller here can trip it — but this is a `pub fn`
591/// taking an arbitrary slice, and narrowing with a bare `as u16` would let a future one write
592/// a value of 1536 or more, which IEEE 802.3 reserves for EtherType. The frame would then stop
593/// being 802.3 at all and every receiver would read it as Ethernet II, the BPDU never parsed.
594/// [`decode_frame`] already rejects exactly this in the other direction.
595pub fn encode_frame(destination: [u8; 6], source: [u8; 6], bpdu: &[u8]) -> Result<Vec<u8>> {
596    let client_len = LLC_HEADER_LEN + bpdu.len();
597    if client_len > MAX_8023_LENGTH {
598        bail!(
599            "802.3 client data is {client_len} octets; the length field only carries a length \
600             up to {MAX_8023_LENGTH} (0x0600 and above are EtherTypes, so a longer frame is read \
601             as Ethernet II and the BPDU is never parsed)"
602        );
603    }
604    let mut frame =
605        Vec::with_capacity(MIN_ETHERNET_FRAME_LEN.max(ETHERNET_HEADER_LEN + client_len));
606    frame.extend_from_slice(&destination);
607    frame.extend_from_slice(&source);
608    frame.extend_from_slice(&(client_len as u16).to_be_bytes());
609    frame.push(LLC_DSAP);
610    frame.push(LLC_SSAP);
611    frame.push(LLC_CONTROL);
612    frame.extend_from_slice(bpdu);
613    while frame.len() < MIN_ETHERNET_FRAME_LEN {
614        frame.push(0x00);
615    }
616    Ok(frame)
617}
618
619/// Parse an 802.3 + LLC frame and hand back the BPDU body.
620///
621/// Rejects anything whose LLC header is not the STP one, which is what distinguishes a BPDU
622/// from every other 802.3 frame on the segment.
623pub fn decode_frame(frame: &[u8]) -> Result<Frame8023> {
624    if frame.len() < ETHERNET_HEADER_LEN + LLC_HEADER_LEN {
625        bail!(
626            "802.3 frame needs at least {} octets to carry an LLC header, got {}",
627            ETHERNET_HEADER_LEN + LLC_HEADER_LEN,
628            frame.len()
629        );
630    }
631    let mut destination = [0u8; 6];
632    destination.copy_from_slice(&frame[0..6]);
633    let mut source = [0u8; 6];
634    source.copy_from_slice(&frame[6..12]);
635
636    let declared_len = u16::from_be_bytes([frame[12], frame[13]]) as usize;
637    if declared_len > MAX_8023_LENGTH {
638        bail!(
639            "not an 802.3 length-encapsulated frame: field at offset 12 is 0x{declared_len:04x}, \
640             which is an EtherType, not a length. BPDUs are never carried in Ethernet II frames."
641        );
642    }
643
644    let (dsap, ssap, control) = (frame[14], frame[15], frame[16]);
645    if dsap != LLC_DSAP || ssap != LLC_SSAP || control != LLC_CONTROL {
646        bail!(
647            "not a BPDU: LLC header is {:02x}/{:02x}/{:02x}, expected {:02x}/{:02x}/{:02x}",
648            dsap,
649            ssap,
650            control,
651            LLC_DSAP,
652            LLC_SSAP,
653            LLC_CONTROL
654        );
655    }
656
657    // Trust the length field over the frame length, so trailing padding is not handed on as
658    // BPDU content — but never read past what actually arrived.
659    let available = frame.len() - ETHERNET_HEADER_LEN - LLC_HEADER_LEN;
660    let bpdu_len = declared_len.saturating_sub(LLC_HEADER_LEN).min(available);
661    let start = ETHERNET_HEADER_LEN + LLC_HEADER_LEN;
662    Ok(Frame8023 {
663        destination,
664        source,
665        payload: frame[start..start + bpdu_len].to_vec(),
666    })
667}