Skip to main content

netget/server/cdp/
codec.rs

1//! Pure CDP frame codec — 802.3 + LLC/SNAP framing, the CDP header, and the TLV body.
2//!
3//! **Nothing in this file touches a socket, a capture handle, the LLM or `AppState`.** Every
4//! function here is a pure transformation between bytes and structured values, which is the
5//! whole point: the raw-Ethernet transport in [`super`] needs packet-capture privilege and
6//! cannot run in the test environment, so the part of CDP that can actually be *wrong* — the
7//! wire format — is isolated where it can be tested exhaustively against literal specification
8//! bytes and against real captures. See `src/server/cdp/CLAUDE.md` for what is proven and what
9//! is not.
10//!
11//! # Frame layout
12//!
13//! ```text
14//! destination MAC (6)  01:00:0C:CC:CC:CC
15//! source MAC      (6)
16//! 802.3 length    (2)  = 8 + len(CDP payload)   -- a LENGTH, not an EtherType
17//! LLC  DSAP       (1)  0xAA
18//! LLC  SSAP       (1)  0xAA
19//! LLC  control    (1)  0x03  (unnumbered information)
20//! SNAP OUI        (3)  00:00:0C  (Cisco)
21//! SNAP protocol   (2)  0x2000    (CDP)
22//! CDP  version    (1)  1 or 2
23//! CDP  TTL        (1)  seconds the neighbour should keep the entry
24//! CDP  checksum   (2)
25//! CDP  TLVs       (n)  type(2) length(2) value(length-4)
26//! ```
27//!
28//! Note the TLV `length` **includes its own 4-byte header**, which is the single most common
29//! mistake when hand-writing CDP: a Device ID of `"myswitch"` (8 bytes) is `00 01 00 0C`, not
30//! `00 01 00 08`.
31
32use anyhow::{anyhow, bail, Result};
33use serde_json::{json, Value};
34use std::net::IpAddr;
35
36// ---------------------------------------------------------------------------------------------
37// Framing constants
38// ---------------------------------------------------------------------------------------------
39
40/// The CDP multicast destination. Every CDP advertisement goes here; there is no unicast CDP.
41pub const CDP_MULTICAST_MAC: [u8; 6] = [0x01, 0x00, 0x0c, 0xcc, 0xcc, 0xcc];
42
43/// LLC DSAP/SSAP for SNAP-encapsulated traffic.
44pub const LLC_SAP_SNAP: u8 = 0xAA;
45/// LLC control byte: unnumbered information.
46pub const LLC_CONTROL_UI: u8 = 0x03;
47/// SNAP OUI for Cisco.
48pub const SNAP_OUI_CISCO: [u8; 3] = [0x00, 0x00, 0x0c];
49/// SNAP protocol id for CDP.
50pub const SNAP_PROTOCOL_CDP: u16 = 0x2000;
51
52/// The complete LLC + SNAP header that precedes every CDP payload.
53pub const LLC_SNAP_HEADER: [u8; 8] = [
54    LLC_SAP_SNAP,
55    LLC_SAP_SNAP,
56    LLC_CONTROL_UI,
57    SNAP_OUI_CISCO[0],
58    SNAP_OUI_CISCO[1],
59    SNAP_OUI_CISCO[2],
60    (SNAP_PROTOCOL_CDP >> 8) as u8,
61    (SNAP_PROTOCOL_CDP & 0xff) as u8,
62];
63
64/// Ethernet header length: destination + source + length field.
65pub const ETHERNET_HEADER_LEN: usize = 14;
66/// LLC + SNAP header length.
67pub const LLC_SNAP_HEADER_LEN: usize = 8;
68/// Offset of the CDP payload inside a complete frame.
69pub const CDP_PAYLOAD_OFFSET: usize = ETHERNET_HEADER_LEN + LLC_SNAP_HEADER_LEN;
70/// version + ttl + checksum.
71pub const CDP_HEADER_LEN: usize = 4;
72
73/// The largest value the 802.3 length field can carry **as a length**.
74///
75/// This is not an MTU preference, it is the boundary of the encapsulation. IEEE 802.3 reserves
76/// every value from `0x0600` (1536) upwards for EtherType, so a frame whose length field is
77/// 1536 or more is read by every receiver as an Ethernet II frame of that protocol rather than
78/// as an 802.3 LLC/SNAP one — the CDP payload behind it is never looked at. 1500 is the largest
79/// length that is unambiguously a length. `src/server/stp/codec.rs` rejects the same thing in
80/// the decode direction for the same reason.
81pub const MAX_8023_LENGTH: usize = 1500;
82
83/// Cap on any single text field the model authors.
84///
85/// CDP has no published specification and its TLV length field is 16 bits, so this is not a
86/// spec limit — it is the point past which the value stops being a device identifier. The
87/// binding constraint is [`MAX_8023_LENGTH`]: the whole advertisement must fit one frame. This
88/// smaller, per-field bound exists only so the error names the offending field instead of
89/// reporting a frame that came out 400 bytes too long.
90pub const MAX_TEXT_TLV: usize = 255;
91
92// ---------------------------------------------------------------------------------------------
93// TLV type codes
94// ---------------------------------------------------------------------------------------------
95
96pub const TLV_DEVICE_ID: u16 = 0x0001;
97pub const TLV_ADDRESSES: u16 = 0x0002;
98pub const TLV_PORT_ID: u16 = 0x0003;
99pub const TLV_CAPABILITIES: u16 = 0x0004;
100pub const TLV_SOFTWARE_VERSION: u16 = 0x0005;
101pub const TLV_PLATFORM: u16 = 0x0006;
102pub const TLV_NATIVE_VLAN: u16 = 0x000A;
103pub const TLV_DUPLEX: u16 = 0x000B;
104pub const TLV_MANAGEMENT_ADDRESS: u16 = 0x0016;
105
106/// Names for the TLV types this codec parses but does not model, so a received advertisement
107/// that carries them can still be described to the model as *what it is* rather than as an
108/// opaque number. Only the type and the length are ever surfaced — never the bytes.
109const KNOWN_OTHER_TLVS: &[(u16, &str)] = &[
110    (0x0007, "ip_prefix"),
111    (0x0008, "protocol_hello"),
112    (0x0009, "vtp_management_domain"),
113    (0x000C, "trust_bitmap_legacy"),
114    (0x000E, "voip_vlan_reply"),
115    (0x000F, "voip_vlan_query"),
116    (0x0010, "power_consumption"),
117    (0x0011, "mtu"),
118    (0x0012, "trust_bitmap"),
119    (0x0013, "untrusted_port_cos"),
120    (0x0014, "system_name"),
121    (0x0015, "system_object_id"),
122    (0x0017, "location"),
123    (0x0018, "external_port_id"),
124    (0x0019, "power_requested"),
125    (0x001A, "power_available"),
126    (0x001B, "port_unidirectional"),
127];
128
129/// Device capability bits, as advertised in the Capabilities TLV.
130///
131/// Named rather than numeric because the model authors these: `["switch", "igmp"]` is something
132/// a language model can produce correctly and `0x28` is not.
133pub const CAPABILITY_FLAGS: &[(u32, &str)] = &[
134    (0x0001, "router"),
135    (0x0002, "transparent_bridge"),
136    (0x0004, "source_route_bridge"),
137    (0x0008, "switch"),
138    (0x0010, "host"),
139    (0x0020, "igmp"),
140    (0x0040, "repeater"),
141    (0x0080, "voip_phone"),
142];
143
144/// Decode a capability bitmask into the names this codec knows.
145///
146/// Bits with no name are reported as `bit_<n>` rather than dropped: a neighbour that sets an
147/// undocumented bit is telling us something, and silently discarding it would make the event
148/// disagree with the wire.
149pub fn capability_names(bits: u32) -> Vec<String> {
150    let mut out = Vec::new();
151    for (mask, name) in CAPABILITY_FLAGS {
152        if bits & mask != 0 {
153            out.push((*name).to_string());
154        }
155    }
156    let known: u32 = CAPABILITY_FLAGS.iter().map(|(m, _)| *m).sum();
157    let mut leftover = bits & !known;
158    let mut bit = 0u32;
159    while leftover != 0 {
160        if leftover & 1 != 0 {
161            out.push(format!("bit_{}", bit));
162        }
163        leftover >>= 1;
164        bit += 1;
165    }
166    out
167}
168
169/// Encode capability names into a bitmask, rejecting anything unrecognised.
170///
171/// Rejecting is deliberate. A silently-ignored capability name would put an advertisement on
172/// the wire that claims less than the operator's prompt asked for, and nothing would say so.
173pub fn capability_bits(names: &[String]) -> Result<u32> {
174    let mut bits = 0u32;
175    for name in names {
176        let lower = name.trim().to_ascii_lowercase();
177        match CAPABILITY_FLAGS.iter().find(|(_, n)| *n == lower) {
178            Some((mask, _)) => bits |= mask,
179            None => {
180                let allowed: Vec<&str> = CAPABILITY_FLAGS.iter().map(|(_, n)| *n).collect();
181                bail!(
182                    "unknown CDP capability '{}'; allowed values are {:?}",
183                    name,
184                    allowed
185                );
186            }
187        }
188    }
189    Ok(bits)
190}
191
192// ---------------------------------------------------------------------------------------------
193// Checksum
194// ---------------------------------------------------------------------------------------------
195
196/// The CDP checksum: a 16-bit one's-complement sum (RFC 1071) over the CDP payload with the
197/// checksum field zeroed — **with Cisco's non-standard treatment of an odd-length payload.**
198///
199/// For an even-length payload this is the plain IP checksum. For an odd-length payload RFC 1071
200/// says to pad with a trailing zero byte, which makes the final word `last << 8`. Cisco's
201/// implementation instead puts the last octet in the *low* half of the final big-endian word and
202/// then compensates for an off-by-one in its own sign handling:
203///
204/// | last byte `L`   | final 16-bit word |
205/// |---|---|
206/// | `L < 0x80`      | `0x00 << 8 \| L`   |
207/// | `L >= 0x80`     | `0xFF << 8 \| (L - 1)` |
208///
209/// This is not a guess. It is transcribed from Wireshark's `packet-cdp.c`, which builds exactly
210/// that padded buffer ("Swap bytes in last word" / "Compensate off-by-one error") before calling
211/// `in_cksum`, and it is corroborated by scapy's independent `_CDPChecksum._check_len`. The two
212/// disagree on one value: scapy tests `last <= 0x80` where Wireshark tests `last & 0x80`, so for
213/// a payload ending in exactly `0x80` scapy produces `0x0080` and Wireshark `0xFF7F`. This
214/// implementation follows **Wireshark**, because Wireshark's is the reading that treats the byte
215/// as signed (`0x80` is negative, so it takes the compensated branch) and because Wireshark is
216/// what an operator will use to check our frames.
217///
218/// Emitting the RFC-1071 form instead is not a cosmetic difference: Wireshark flags the packet
219/// `[incorrect]` and a real Cisco device discards it, which is precisely the failure mode this
220/// project's OSPF checksum bug had (`src/server/ospf/CLAUDE.md`).
221pub fn checksum(data: &[u8]) -> u16 {
222    let mut buf: Vec<u8> = data.to_vec();
223    if buf.len() % 2 == 1 {
224        let n = buf.len();
225        let last = buf[n - 1];
226        buf[n - 1] = 0x00;
227        buf.push(last);
228        if last & 0x80 != 0 {
229            buf[n] = last.wrapping_sub(1);
230            buf[n - 1] = 0xFF;
231        }
232    }
233    // `buf` is even-length by construction above, so every pair is a complete big-endian word.
234    let mut sum: u32 = 0;
235    let mut i = 0;
236    while i + 1 < buf.len() {
237        sum += u16::from_be_bytes([buf[i], buf[i + 1]]) as u32;
238        i += 2;
239    }
240    while (sum >> 16) != 0 {
241        sum = (sum & 0xffff) + (sum >> 16);
242    }
243    !(sum as u16)
244}
245
246/// The checksum a CDP payload *should* carry: [`checksum`] over the payload with bytes 2..4
247/// forced to zero.
248pub fn payload_checksum(payload: &[u8]) -> Result<u16> {
249    if payload.len() < CDP_HEADER_LEN {
250        bail!(
251            "CDP payload is {} bytes, shorter than the 4-byte header",
252            payload.len()
253        );
254    }
255    let mut copy = payload.to_vec();
256    copy[2] = 0;
257    copy[3] = 0;
258    Ok(checksum(&copy))
259}
260
261// ---------------------------------------------------------------------------------------------
262// MAC helpers
263// ---------------------------------------------------------------------------------------------
264
265/// Render a MAC as the canonical lowercase colon form.
266pub fn mac_to_string(mac: &[u8; 6]) -> String {
267    format!(
268        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
269        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
270    )
271}
272
273/// Parse `aa:bb:cc:dd:ee:ff` or `aa-bb-cc-dd-ee-ff` into six bytes.
274pub fn parse_mac(text: &str) -> Result<[u8; 6]> {
275    let parts: Vec<&str> = text.split(['-', ':']).collect();
276    if parts.len() != 6 {
277        bail!(
278            "'{}' is not a MAC address: expected six colon- or dash-separated octets",
279            text
280        );
281    }
282    let mut mac = [0u8; 6];
283    for (i, part) in parts.iter().enumerate() {
284        mac[i] = u8::from_str_radix(part, 16)
285            .map_err(|_| anyhow!("'{}' is not a MAC address: '{}' is not hex", text, part))?;
286    }
287    Ok(mac)
288}
289
290// ---------------------------------------------------------------------------------------------
291// Model types
292// ---------------------------------------------------------------------------------------------
293
294/// One entry of an Addresses or Management Address TLV.
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct CdpAddress {
297    /// `"ipv4"` or `"ipv6"`.
298    pub protocol: String,
299    /// Textual address.
300    pub address: String,
301}
302
303impl CdpAddress {
304    pub fn new(addr: IpAddr) -> Self {
305        Self {
306            protocol: match addr {
307                IpAddr::V4(_) => "ipv4".to_string(),
308                IpAddr::V6(_) => "ipv6".to_string(),
309            },
310            address: addr.to_string(),
311        }
312    }
313
314    fn to_json(&self) -> Value {
315        json!({ "protocol": self.protocol, "address": self.address })
316    }
317}
318
319/// Interface duplex, as advertised in the Duplex TLV.
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub enum Duplex {
322    Half,
323    Full,
324}
325
326impl Duplex {
327    pub fn as_str(self) -> &'static str {
328        match self {
329            Duplex::Half => "half",
330            Duplex::Full => "full",
331        }
332    }
333
334    fn from_byte(b: u8) -> Self {
335        if b == 0 {
336            Duplex::Half
337        } else {
338            Duplex::Full
339        }
340    }
341
342    fn to_byte(self) -> u8 {
343        match self {
344            Duplex::Half => 0x00,
345            Duplex::Full => 0x01,
346        }
347    }
348}
349
350/// A TLV this codec recognises the existence of but does not model.
351///
352/// Only the type code, its name where known, and its length are kept. The bytes are deliberately
353/// dropped: the project rule is that no event carries raw bytes or base64 to the model, and a
354/// length is the honest amount of information available without modelling the TLV.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct OtherTlv {
357    pub type_code: u16,
358    pub name: &'static str,
359    pub length: usize,
360}
361
362/// A CDP advertisement, in the terms the LLM reasons about.
363#[derive(Debug, Clone, PartialEq, Eq)]
364pub struct CdpAdvertisement {
365    pub version: u8,
366    pub ttl: u8,
367    pub device_id: Option<String>,
368    pub port_id: Option<String>,
369    pub platform: Option<String>,
370    pub software_version: Option<String>,
371    pub capabilities: Option<u32>,
372    pub native_vlan: Option<u16>,
373    pub duplex: Option<Duplex>,
374    pub addresses: Vec<CdpAddress>,
375    pub management_addresses: Vec<CdpAddress>,
376    pub other_tlvs: Vec<OtherTlv>,
377}
378
379impl Default for CdpAdvertisement {
380    fn default() -> Self {
381        // CDPv2 with the Cisco default hold time of 180 seconds.
382        Self {
383            version: 2,
384            ttl: 180,
385            device_id: None,
386            port_id: None,
387            platform: None,
388            software_version: None,
389            capabilities: None,
390            native_vlan: None,
391            duplex: None,
392            addresses: Vec::new(),
393            management_addresses: Vec::new(),
394            other_tlvs: Vec::new(),
395        }
396    }
397}
398
399/// What [`decode_payload`] found, including the checksum verdict.
400///
401/// The verdict is carried alongside rather than inside [`CdpAdvertisement`] so that the
402/// advertisement type stays a pure description of *what to say*, usable unchanged as the input
403/// to [`encode_payload`].
404#[derive(Debug, Clone, PartialEq, Eq)]
405pub struct DecodedCdp {
406    pub advertisement: CdpAdvertisement,
407    pub declared_checksum: u16,
408    pub computed_checksum: u16,
409}
410
411impl DecodedCdp {
412    pub fn checksum_valid(&self) -> bool {
413        self.declared_checksum == self.computed_checksum
414    }
415}
416
417// ---------------------------------------------------------------------------------------------
418// Encoding
419// ---------------------------------------------------------------------------------------------
420
421/// Check a model-authored text field before it becomes a TLV.
422///
423/// **A CDP text TLV is an entry in somebody's neighbour table.** `show cdp neighbors detail`,
424/// an NMS topology view and this server's own
425/// `CDP advertisement from {device_id} ({platform}) on {port_id}` log line all print these
426/// verbatim, none of them quoting. A newline in `device_id` therefore forges a second
427/// neighbour in every one of those displays — which is precisely what impersonating a Cisco
428/// device is for. Nothing a real device puts in these fields is a control character.
429///
430/// Refused rather than stripped: on this side the model wrote the string and can be told, and
431/// silently rewriting its answer would make the frame disagree with the decision the log
432/// records. [`sanitize_wire_text`] takes the opposite exit for a *neighbour's* text.
433fn check_text_field(field: &str, text: &str) -> Result<()> {
434    if let Some((index, bad)) = text.char_indices().find(|(_, c)| c.is_control()) {
435        bail!(
436            "'{}' contains a control character (U+{:04X}) at byte {}. CDP text is copied \
437             verbatim into a neighbour's device table and into log lines, so a newline or NUL \
438             there forges a whole neighbour entry. Send printable text only.",
439            field,
440            bad as u32,
441            index
442        );
443    }
444    if text.len() > MAX_TEXT_TLV {
445        bail!(
446            "'{}' is {} bytes; CDP identifiers are capped at {} here so the advertisement still \
447             fits one Ethernet frame",
448            field,
449            text.len(),
450            MAX_TEXT_TLV
451        );
452    }
453    Ok(())
454}
455
456/// Read an **identifier** TLV off the wire, with control characters replaced by a space.
457///
458/// Device ID, Port ID and Platform come straight back out in
459/// `CDP advertisement from {device_id} ({platform}) on {port_id}`, and
460/// `src/protocol/log_template.rs` quotes nothing — so a neighbour that puts a newline in its
461/// Device ID forges a whole extra log line, which is the entire point of impersonating a Cisco
462/// device. None of the three is ever legitimately multi-line, so nothing real is lost. A
463/// neighbour cannot be asked to resend, which is why this strips where [`check_text_field`]
464/// refuses.
465///
466/// **Software Version deliberately does not go through this.** A real IOS version banner *is*
467/// multi-line, it is the single most useful thing a recon operator reads off a CDP frame, and
468/// it appears in no log template — only in the JSON-escaped trace line and in the event.
469fn identifier_text(value: &[u8]) -> String {
470    crate::utils::sanitize::line_field(&String::from_utf8_lossy(value))
471}
472
473fn push_tlv(out: &mut Vec<u8>, type_code: u16, value: &[u8]) -> Result<()> {
474    let len = value
475        .len()
476        .checked_add(4)
477        .and_then(|l| u16::try_from(l).ok())
478        .ok_or_else(|| {
479            anyhow!(
480                "CDP TLV 0x{:04x} value is {} bytes, too long for a 16-bit length",
481                type_code,
482                value.len()
483            )
484        })?;
485    out.extend_from_slice(&type_code.to_be_bytes());
486    out.extend_from_slice(&len.to_be_bytes());
487    out.extend_from_slice(value);
488    Ok(())
489}
490
491fn encode_address_list(addresses: &[CdpAddress]) -> Result<Vec<u8>> {
492    let mut body = Vec::new();
493    body.extend_from_slice(&(addresses.len() as u32).to_be_bytes());
494    for entry in addresses {
495        let parsed: IpAddr = entry
496            .address
497            .parse()
498            .map_err(|_| anyhow!("'{}' is not an IP address", entry.address))?;
499        match parsed {
500            IpAddr::V4(v4) => {
501                // Protocol type 1 (NLPID), 1-byte protocol 0xCC (IP).
502                body.push(0x01);
503                body.push(0x01);
504                body.push(0xCC);
505                body.extend_from_slice(&4u16.to_be_bytes());
506                body.extend_from_slice(&v4.octets());
507            }
508            IpAddr::V6(v6) => {
509                // Protocol type 2 (802.2), 8-byte SNAP protocol AA AA 03 00 00 00 86 DD.
510                body.push(0x02);
511                body.push(0x08);
512                body.extend_from_slice(&[0xAA, 0xAA, 0x03, 0x00, 0x00, 0x00, 0x86, 0xDD]);
513                body.extend_from_slice(&16u16.to_be_bytes());
514                body.extend_from_slice(&v6.octets());
515            }
516        }
517    }
518    Ok(body)
519}
520
521/// Serialise an advertisement to a CDP payload, checksum included.
522///
523/// TLVs are emitted in ascending type order, which is what real Cisco devices do and what makes
524/// the output diffable against a capture.
525pub fn encode_payload(ad: &CdpAdvertisement) -> Result<Vec<u8>> {
526    if ad.version != 1 && ad.version != 2 {
527        bail!("CDP version must be 1 or 2, got {}", ad.version);
528    }
529
530    let mut out = Vec::with_capacity(128);
531    out.push(ad.version);
532    out.push(ad.ttl);
533    out.extend_from_slice(&[0, 0]); // checksum placeholder
534
535    if let Some(id) = &ad.device_id {
536        check_text_field("device_id", id)?;
537        push_tlv(&mut out, TLV_DEVICE_ID, id.as_bytes())?;
538    }
539    if !ad.addresses.is_empty() {
540        let body = encode_address_list(&ad.addresses)?;
541        push_tlv(&mut out, TLV_ADDRESSES, &body)?;
542    }
543    if let Some(port) = &ad.port_id {
544        check_text_field("port_id", port)?;
545        push_tlv(&mut out, TLV_PORT_ID, port.as_bytes())?;
546    }
547    if let Some(caps) = ad.capabilities {
548        push_tlv(&mut out, TLV_CAPABILITIES, &caps.to_be_bytes())?;
549    }
550    if let Some(version) = &ad.software_version {
551        // Not `check_text_field`: a real IOS banner is multi-line, and refusing a newline here
552        // would make the most realistic value the model can send unencodable. It carries no
553        // control character into a log line (see `identifier_text`), so only the length matters
554        // — and MAX_8023_LENGTH in `encode_frame` is the real bound on a banner this long.
555        push_tlv(&mut out, TLV_SOFTWARE_VERSION, version.as_bytes())?;
556    }
557    if let Some(platform) = &ad.platform {
558        check_text_field("platform", platform)?;
559        push_tlv(&mut out, TLV_PLATFORM, platform.as_bytes())?;
560    }
561    if let Some(vlan) = ad.native_vlan {
562        push_tlv(&mut out, TLV_NATIVE_VLAN, &vlan.to_be_bytes())?;
563    }
564    if let Some(duplex) = ad.duplex {
565        push_tlv(&mut out, TLV_DUPLEX, &[duplex.to_byte()])?;
566    }
567    if !ad.management_addresses.is_empty() {
568        let body = encode_address_list(&ad.management_addresses)?;
569        push_tlv(&mut out, TLV_MANAGEMENT_ADDRESS, &body)?;
570    }
571
572    let cksum = payload_checksum(&out)?;
573    out[2..4].copy_from_slice(&cksum.to_be_bytes());
574    Ok(out)
575}
576
577/// Wrap a CDP payload in the 802.3 + LLC/SNAP header addressed to the CDP multicast group.
578///
579/// The 802.3 length field carries the true length of everything after it (LLC + SNAP + payload).
580/// No padding to the 60-byte Ethernet minimum is added: on both transports something else is
581/// responsible for it — a NIC pads on transmit, and the UDP test transport has no minimum at
582/// all — and padding here would make the emitted bytes differ from the bytes the codec tests
583/// assert.
584pub fn encode_frame(source_mac: [u8; 6], payload: &[u8]) -> Result<Vec<u8>> {
585    let body_len = LLC_SNAP_HEADER_LEN + payload.len();
586    // Bound the number BEFORE narrowing it. `u16::try_from` alone accepts everything up to
587    // 65535, and any value from 1536 up is an EtherType rather than a length (see
588    // MAX_8023_LENGTH) — so a long advertisement would not merely be oversized, it would
589    // silently stop being an 802.3 frame and no receiver would ever look at the CDP behind it.
590    if body_len > MAX_8023_LENGTH {
591        bail!(
592            "CDP frame body is {} bytes; the 802.3 length field only carries a length up to {} \
593             (0x0600 and above are EtherTypes, so a longer frame is read as Ethernet II and the \
594             CDP payload is never parsed)",
595            body_len,
596            MAX_8023_LENGTH
597        );
598    }
599    let length_field = u16::try_from(body_len)
600        .map_err(|_| anyhow!("CDP frame body is {} bytes, too long for 802.3", body_len))?;
601
602    let mut frame = Vec::with_capacity(ETHERNET_HEADER_LEN + body_len);
603    frame.extend_from_slice(&CDP_MULTICAST_MAC);
604    frame.extend_from_slice(&source_mac);
605    frame.extend_from_slice(&length_field.to_be_bytes());
606    frame.extend_from_slice(&LLC_SNAP_HEADER);
607    frame.extend_from_slice(payload);
608    Ok(frame)
609}
610
611// ---------------------------------------------------------------------------------------------
612// Decoding
613// ---------------------------------------------------------------------------------------------
614
615/// The addressing of a received frame.
616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617pub struct FrameHeader {
618    pub destination_mac: [u8; 6],
619    pub source_mac: [u8; 6],
620    /// The 802.3 length field as it appeared on the wire.
621    pub declared_length: u16,
622}
623
624/// Split a complete Ethernet frame into its header and the CDP payload.
625///
626/// The LLC/SNAP header is checked byte for byte — DSAP, SSAP, control, OUI and protocol id — so
627/// anything that is not CDP is rejected here rather than being handed to the TLV parser. The
628/// 802.3 length field is *not* enforced against the captured length: a captured frame is padded
629/// to the Ethernet minimum and may be truncated by the snaplen, so requiring agreement would
630/// reject real traffic. It is reported instead, and the TLV walk is bounded by the bytes
631/// actually present.
632pub fn decode_frame(frame: &[u8]) -> Result<(FrameHeader, &[u8])> {
633    if frame.len() < CDP_PAYLOAD_OFFSET {
634        bail!(
635            "frame is {} bytes, too short for an 802.3 + LLC/SNAP CDP header ({} needed)",
636            frame.len(),
637            CDP_PAYLOAD_OFFSET
638        );
639    }
640    let mut destination_mac = [0u8; 6];
641    destination_mac.copy_from_slice(&frame[0..6]);
642    let mut source_mac = [0u8; 6];
643    source_mac.copy_from_slice(&frame[6..12]);
644    let declared_length = u16::from_be_bytes([frame[12], frame[13]]);
645
646    let llc_snap = &frame[ETHERNET_HEADER_LEN..CDP_PAYLOAD_OFFSET];
647    if llc_snap != LLC_SNAP_HEADER {
648        bail!(
649            "not a CDP frame: LLC/SNAP header is {:02x?}, expected {:02x?} \
650             (DSAP/SSAP 0xAA, control 0x03, OUI 00:00:0c, protocol 0x2000)",
651            llc_snap,
652            LLC_SNAP_HEADER
653        );
654    }
655
656    Ok((
657        FrameHeader {
658            destination_mac,
659            source_mac,
660            declared_length,
661        },
662        &frame[CDP_PAYLOAD_OFFSET..],
663    ))
664}
665
666fn decode_address_list(body: &[u8]) -> Vec<CdpAddress> {
667    let mut out = Vec::new();
668    if body.len() < 4 {
669        return out;
670    }
671    let count = u32::from_be_bytes([body[0], body[1], body[2], body[3]]) as usize;
672    let mut offset = 4usize;
673    // Bounded by the declared count *and* by the bytes present, so a hostile count cannot make
674    // this spin or over-read.
675    while out.len() < count.min(64) && offset + 2 <= body.len() {
676        let _protocol_type = body[offset];
677        let protocol_len = body[offset + 1] as usize;
678        offset += 2;
679        if offset + protocol_len + 2 > body.len() {
680            break;
681        }
682        let protocol = &body[offset..offset + protocol_len];
683        offset += protocol_len;
684        let addr_len = u16::from_be_bytes([body[offset], body[offset + 1]]) as usize;
685        offset += 2;
686        if offset + addr_len > body.len() {
687            break;
688        }
689        let addr = &body[offset..offset + addr_len];
690        offset += addr_len;
691
692        // Identify by address length plus the NLPID/SNAP protocol selector, which is how the
693        // two families are actually distinguished on the wire.
694        if addr_len == 4 && protocol == [0xCC] {
695            out.push(CdpAddress {
696                protocol: "ipv4".to_string(),
697                address: std::net::Ipv4Addr::new(addr[0], addr[1], addr[2], addr[3]).to_string(),
698            });
699        } else if addr_len == 16 {
700            let mut octets = [0u8; 16];
701            octets.copy_from_slice(addr);
702            out.push(CdpAddress {
703                protocol: "ipv6".to_string(),
704                address: std::net::Ipv6Addr::from(octets).to_string(),
705            });
706        }
707        // Anything else (CLNS, DECnet, AppleTalk...) is skipped rather than guessed at.
708    }
709    out
710}
711
712/// Parse a CDP payload into an advertisement plus the checksum verdict.
713pub fn decode_payload(payload: &[u8]) -> Result<DecodedCdp> {
714    if payload.len() < CDP_HEADER_LEN {
715        bail!(
716            "CDP payload is {} bytes, shorter than the 4-byte header",
717            payload.len()
718        );
719    }
720    let version = payload[0];
721    let ttl = payload[1];
722    let declared_checksum = u16::from_be_bytes([payload[2], payload[3]]);
723    let computed_checksum = payload_checksum(payload)?;
724
725    let mut ad = CdpAdvertisement {
726        version,
727        ttl,
728        ..CdpAdvertisement::default()
729    };
730
731    let mut offset = CDP_HEADER_LEN;
732    while offset + 4 <= payload.len() {
733        let type_code = u16::from_be_bytes([payload[offset], payload[offset + 1]]);
734        let declared = u16::from_be_bytes([payload[offset + 2], payload[offset + 3]]) as usize;
735        // A TLV length counts its own 4-byte header. Anything below that would not advance the
736        // cursor, so stop rather than loop forever on a malformed advertisement.
737        if declared < 4 {
738            break;
739        }
740        let end = offset + declared;
741        if end > payload.len() {
742            break;
743        }
744        let value = &payload[offset + 4..end];
745
746        match type_code {
747            // The three identifier TLVs are interpolated unquoted into this protocol's own
748            // log line, so they go through `identifier_text`; Software Version does not,
749            // and is legitimately a multi-line banner.
750            TLV_DEVICE_ID => ad.device_id = Some(identifier_text(value)),
751            TLV_PORT_ID => ad.port_id = Some(identifier_text(value)),
752            TLV_PLATFORM => ad.platform = Some(identifier_text(value)),
753            TLV_SOFTWARE_VERSION => {
754                ad.software_version = Some(String::from_utf8_lossy(value).into_owned())
755            }
756            TLV_CAPABILITIES if value.len() >= 4 => {
757                ad.capabilities = Some(u32::from_be_bytes([value[0], value[1], value[2], value[3]]))
758            }
759            TLV_NATIVE_VLAN if value.len() >= 2 => {
760                ad.native_vlan = Some(u16::from_be_bytes([value[0], value[1]]))
761            }
762            TLV_DUPLEX if !value.is_empty() => ad.duplex = Some(Duplex::from_byte(value[0])),
763            TLV_ADDRESSES => ad.addresses = decode_address_list(value),
764            TLV_MANAGEMENT_ADDRESS => ad.management_addresses = decode_address_list(value),
765            _ => ad.other_tlvs.push(OtherTlv {
766                type_code,
767                name: KNOWN_OTHER_TLVS
768                    .iter()
769                    .find(|(c, _)| *c == type_code)
770                    .map(|(_, n)| *n)
771                    .unwrap_or("unknown"),
772                length: value.len(),
773            }),
774        }
775
776        offset = end;
777    }
778
779    Ok(DecodedCdp {
780        advertisement: ad,
781        declared_checksum,
782        computed_checksum,
783    })
784}
785
786// ---------------------------------------------------------------------------------------------
787// JSON bridges
788// ---------------------------------------------------------------------------------------------
789
790fn addresses_json(list: &[CdpAddress]) -> Value {
791    Value::Array(list.iter().map(CdpAddress::to_json).collect())
792}
793
794impl DecodedCdp {
795    /// The event body for `cdp_neighbor_advertisement`.
796    ///
797    /// Every field is structured — strings, numbers, named capability flags. Nothing here is a
798    /// byte array, a hex string or base64, which is the project rule for anything the model
799    /// reads (root `CLAUDE.md`, "Action & event design rules").
800    pub fn to_event_data(&self, header: &FrameHeader, connection_id: &str) -> Value {
801        let ad = &self.advertisement;
802        json!({
803            "connection_id": connection_id,
804            "source_mac": mac_to_string(&header.source_mac),
805            "destination_mac": mac_to_string(&header.destination_mac),
806            "version": ad.version,
807            "ttl": ad.ttl,
808            "device_id": ad.device_id,
809            "port_id": ad.port_id,
810            "platform": ad.platform,
811            "software_version": ad.software_version,
812            "capabilities": ad.capabilities.map(capability_names).unwrap_or_default(),
813            "capabilities_value": ad.capabilities,
814            "native_vlan": ad.native_vlan,
815            "duplex": ad.duplex.map(|d| d.as_str()),
816            "addresses": addresses_json(&ad.addresses),
817            "management_addresses": addresses_json(&ad.management_addresses),
818            "checksum_valid": self.checksum_valid(),
819            "other_tlvs": Value::Array(
820                ad.other_tlvs
821                    .iter()
822                    .map(|t| json!({ "type": t.type_code, "name": t.name, "length": t.length }))
823                    .collect(),
824            ),
825        })
826    }
827}
828
829fn parse_address_field(field: &str, value: &Value) -> Result<Vec<CdpAddress>> {
830    let Some(items) = value.as_array() else {
831        bail!("'{}' must be an array of IP addresses", field);
832    };
833    let mut out = Vec::new();
834    for item in items {
835        let text = match item {
836            Value::String(s) => s.clone(),
837            Value::Object(obj) => obj
838                .get("address")
839                .and_then(|v| v.as_str())
840                .map(|s| s.to_string())
841                .ok_or_else(|| anyhow!("each '{}' entry needs an 'address' string", field))?,
842            other => bail!(
843                "each '{}' entry must be an IP address string, got {}",
844                field,
845                other
846            ),
847        };
848        let parsed: IpAddr = text
849            .parse()
850            .map_err(|_| anyhow!("'{}' in '{}' is not an IP address", text, field))?;
851        out.push(CdpAddress::new(parsed));
852    }
853    Ok(out)
854}
855
856fn optional_string(action: &Value, key: &str) -> Result<Option<String>> {
857    match action.get(key) {
858        None | Some(Value::Null) => Ok(None),
859        Some(Value::String(s)) => Ok(Some(s.clone())),
860        Some(other) => bail!("'{}' must be a string, got {}", key, other),
861    }
862}
863
864impl CdpAdvertisement {
865    /// Build an advertisement from a `send_cdp_advertisement` action.
866    ///
867    /// Pure and total: every rejection is an `Err` naming the offending field, so the action
868    /// executor can validate the model's answer without touching a socket. This is also what
869    /// makes the declared `example` executable, which `tests/executable_examples_test.rs`
870    /// checks across the whole tree.
871    pub fn from_action(action: &Value) -> Result<Self> {
872        let mut ad = CdpAdvertisement::default();
873
874        if let Some(v) = action.get("version") {
875            let n = v
876                .as_u64()
877                .ok_or_else(|| anyhow!("'version' must be 1 or 2, got {}", v))?;
878            if n != 1 && n != 2 {
879                bail!("'version' must be 1 or 2, got {}", n);
880            }
881            ad.version = n as u8;
882        }
883
884        if let Some(v) = action.get("ttl") {
885            let n = v
886                .as_u64()
887                .ok_or_else(|| anyhow!("'ttl' must be a number of seconds, got {}", v))?;
888            if n > 255 {
889                bail!("'ttl' must be 0-255 seconds (CDP carries it in one byte), got {n}");
890            }
891            ad.ttl = n as u8;
892        }
893
894        ad.device_id = optional_string(action, "device_id")?;
895        ad.port_id = optional_string(action, "port_id")?;
896        ad.platform = optional_string(action, "platform")?;
897        ad.software_version = optional_string(action, "software_version")?;
898
899        // A CDP advertisement with no Device ID is not an advertisement: the neighbour keys its
900        // table on it. Refuse rather than emit a nameless frame.
901        if ad
902            .device_id
903            .as_deref()
904            .map(str::trim)
905            .unwrap_or("")
906            .is_empty()
907        {
908            bail!("send_cdp_advertisement requires a non-empty 'device_id'");
909        }
910
911        match action.get("capabilities") {
912            None | Some(Value::Null) => {}
913            Some(Value::Array(items)) => {
914                let names: Vec<String> = items
915                    .iter()
916                    .map(|i| {
917                        i.as_str()
918                            .map(|s| s.to_string())
919                            .ok_or_else(|| anyhow!("'capabilities' entries must be strings"))
920                    })
921                    .collect::<Result<_>>()?;
922                ad.capabilities = Some(capability_bits(&names)?);
923            }
924            Some(Value::Number(n)) => {
925                let raw = n
926                    .as_u64()
927                    .ok_or_else(|| anyhow!("'capabilities' must be a non-negative number"))?;
928                ad.capabilities = Some(u32::try_from(raw).map_err(|_| {
929                    anyhow!("'capabilities' bitmask must fit in 32 bits, got {}", raw)
930                })?);
931            }
932            Some(other) => bail!(
933                "'capabilities' must be an array of names (e.g. [\"switch\",\"igmp\"]) \
934                 or a numeric bitmask, got {}",
935                other
936            ),
937        }
938
939        if let Some(v) = action.get("native_vlan") {
940            if !v.is_null() {
941                let n = v
942                    .as_u64()
943                    .ok_or_else(|| anyhow!("'native_vlan' must be a number, got {}", v))?;
944                if n > 4094 {
945                    bail!("'native_vlan' must be 0-4094, got {}", n);
946                }
947                ad.native_vlan = Some(n as u16);
948            }
949        }
950
951        match action.get("duplex") {
952            None | Some(Value::Null) => {}
953            Some(Value::String(s)) => {
954                ad.duplex = Some(match s.trim().to_ascii_lowercase().as_str() {
955                    "full" => Duplex::Full,
956                    "half" => Duplex::Half,
957                    other => bail!("'duplex' must be \"full\" or \"half\", got \"{}\"", other),
958                })
959            }
960            Some(other) => bail!("'duplex' must be \"full\" or \"half\", got {}", other),
961        }
962
963        if let Some(v) = action.get("addresses") {
964            if !v.is_null() {
965                ad.addresses = parse_address_field("addresses", v)?;
966            }
967        }
968        if let Some(v) = action.get("management_addresses") {
969            if !v.is_null() {
970                ad.management_addresses = parse_address_field("management_addresses", v)?;
971            }
972        }
973
974        Ok(ad)
975    }
976
977    /// A one-line summary for the status stream and the access log.
978    pub fn summary(&self) -> String {
979        format!(
980            "device_id={} port_id={} platform={} native_vlan={} ttl={}",
981            self.device_id.as_deref().unwrap_or("-"),
982            self.port_id.as_deref().unwrap_or("-"),
983            self.platform.as_deref().unwrap_or("-"),
984            self.native_vlan
985                .map(|v| v.to_string())
986                .unwrap_or_else(|| "-".to_string()),
987            self.ttl
988        )
989    }
990}