Skip to main content

bgpkit_parser/parser/mrt/
dissect.rs

1//! Best-effort dissection of MRT records.
2//!
3//! Produces a [`DissectionNode`] tree over the whole record — common header
4//! fields, the BGP4MP subheader for the message subtypes, and then delegation
5//! into the embedded BGP message (see [`crate::parser::bgp::dissect`]) — so
6//! every field of every layer shares one byte-offset coordinate space. Like
7//! the BGP dissector this is a separate opt-in pass: it never runs on any
8//! default iterator path and never fails on malformed input.
9
10use crate::models::{AsnLength, DissectionNode};
11use crate::parser::bgp::dissect::{dissect_bgp_message_base, read_u16};
12use crate::parser::mrt::RawMrtRecord;
13
14fn entry_type_name(entry_type: u16) -> &'static str {
15    match entry_type {
16        12 => "TABLE_DUMP",
17        13 => "TABLE_DUMP_V2",
18        16 => "BGP4MP",
19        17 => "BGP4MP_ET",
20        32 => "ISIS",
21        33 => "ISIS_ET",
22        48 => "OSPFv3",
23        49 => "OSPFv3_ET",
24        _ => "UNKNOWN",
25    }
26}
27
28/// Dissect one MRT record from its raw framing.
29///
30/// Offsets are relative to the start of the record (common header first). For
31/// ET records the 4-byte microsecond timestamp is part of the header.
32pub fn dissect_mrt_record(raw: &RawMrtRecord) -> DissectionNode {
33    let mut buf = Vec::with_capacity(raw.total_bytes_len());
34    buf.extend_from_slice(&raw.header_bytes);
35    buf.extend_from_slice(&raw.message_bytes);
36    dissect_mrt_bytes(&buf)
37}
38
39/// Dissect raw MRT record bytes, best-effort.
40///
41/// Accepts possibly-truncated input (e.g. bytes retained from a failed
42/// parse): the tree covers whatever fields could be walked.
43pub fn dissect_mrt_bytes(data: &[u8]) -> DissectionNode {
44    let mut root = DissectionNode::new(
45        "mrt",
46        format!("MRT record ({} bytes)", data.len()),
47        0,
48        data.len() as u32,
49    );
50    if data.len() < 12 {
51        if !data.is_empty() {
52            root.children.push(DissectionNode::new(
53                "mrt.header",
54                format!("Common header (truncated, {} of 12 bytes)", data.len()),
55                0,
56                data.len() as u32,
57            ));
58        }
59        return root;
60    }
61
62    let entry_type = read_u16(data, 4).unwrap_or(0);
63    let sub_type = read_u16(data, 6).unwrap_or(0);
64
65    // ET variants (RFC 6396) carry a 4-byte microsecond field after the
66    // standard 12-byte header. An ET record truncated mid-microsecond field
67    // keeps those bytes in a truncated extended-header node instead of
68    // mislabeling them as message body.
69    let is_et = matches!(entry_type, 17 | 33 | 49);
70    let header_len = if is_et { 16 } else { 12 };
71
72    let mut header = DissectionNode::new(
73        "mrt.header",
74        "Common header",
75        0,
76        header_len.min(data.len()) as u32,
77    );
78    let timestamp = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
79    header.children.push(DissectionNode::new(
80        "mrt.header.timestamp",
81        format!("Timestamp: {timestamp}"),
82        0,
83        4,
84    ));
85    header.children.push(DissectionNode::new(
86        "mrt.header.type",
87        format!("Type: {} ({entry_type})", entry_type_name(entry_type)),
88        4,
89        2,
90    ));
91    header.children.push(DissectionNode::new(
92        "mrt.header.subtype",
93        format!("Subtype: {sub_type}"),
94        6,
95        2,
96    ));
97    let length = u32::from_be_bytes([data[8], data[9], data[10], data[11]]);
98    header.children.push(DissectionNode::new(
99        "mrt.header.length",
100        format!("Length: {length}"),
101        8,
102        4,
103    ));
104    if is_et {
105        if data.len() >= 16 {
106            header.children.push(DissectionNode::new(
107                "mrt.header.microsecond",
108                format!(
109                    "Microsecond timestamp: {}",
110                    u32::from_be_bytes([data[12], data[13], data[14], data[15]])
111                ),
112                12,
113                4,
114            ));
115        } else {
116            header.children.push(DissectionNode::new(
117                "mrt.header.microsecond",
118                format!(
119                    "Microsecond timestamp (truncated: {} of 4 bytes)",
120                    data.len() - 12
121                ),
122                12,
123                (data.len() - 12) as u32,
124            ));
125            root.children.push(header);
126            return root;
127        }
128    }
129    root.children.push(header);
130
131    let body = &data[header_len..];
132    let body_base = header_len as u32;
133    if body.is_empty() {
134        return root;
135    }
136
137    let body_node = match entry_type {
138        16 | 17 => dissect_bgp4mp(body, body_base, sub_type),
139        _ => DissectionNode::new(
140            "mrt.body",
141            format!("Message body ({} bytes)", body.len()),
142            body_base,
143            body.len() as u32,
144        ),
145    };
146    root.children.push(body_node);
147    root
148}
149
150/// Dissect the body of a BGP4MP / BGP4MP_ET record: the peer/local subheader
151/// plus the embedded BGP message.
152///
153/// Message subtypes carry peer AS, local AS, interface index, AFI, peer and
154/// local addresses, then the BGP message. State-change subtypes replace the
155/// BGP message with old/new session states. Old Zebra records sometimes omit
156/// the interface/AFI/address section entirely (see
157/// `parse_bgp4mp_message`); that layout is detected and noted.
158fn dissect_bgp4mp(data: &[u8], base: u32, sub_type: u16) -> DissectionNode {
159    let as4 = matches!(sub_type, 4 | 5 | 7 | 9 | 11);
160    let asn_size = if as4 { 4 } else { 2 };
161    let add_path = matches!(sub_type, 8..=11);
162    let asn_len = if as4 {
163        AsnLength::Bits32
164    } else {
165        AsnLength::Bits16
166    };
167    let is_message = !matches!(sub_type, 0 | 5);
168
169    let mut node = DissectionNode::new(
170        "mrt.bgp4mp",
171        format!(
172            "BGP4MP {} ({sub_type})",
173            if is_message {
174                "message"
175            } else {
176                "state change"
177            }
178        ),
179        base,
180        data.len() as u32,
181    );
182
183    let mut pos = 0usize;
184
185    if data.len() < 2 * asn_size {
186        node.children.push(DissectionNode::new(
187            "mrt.bgp4mp.truncated",
188            format!(
189                "Subheader (truncated: {} of at least {} bytes)",
190                data.len(),
191                2 * asn_size
192            ),
193            base,
194            data.len() as u32,
195        ));
196        return node;
197    }
198
199    let peer_asn = read_asn(data, 0, asn_size);
200    let local_asn = read_asn(data, asn_size, asn_size);
201    push_field(
202        &mut node,
203        "mrt.bgp4mp.peer_asn",
204        format!("Peer ASN: {peer_asn}"),
205        base,
206        asn_size,
207    );
208    pos += asn_size;
209    push_field(
210        &mut node,
211        "mrt.bgp4mp.local_asn",
212        format!("Local ASN: {local_asn}"),
213        base + pos as u32,
214        asn_size,
215    );
216    pos += asn_size;
217
218    // Old Zebra compatibility: some records jump straight from the two ASNs
219    // to the BGP marker, omitting interface index, AFI, and addresses.
220    let rest = &data[pos..];
221    if rest.len() >= 16 && rest[..16].iter().all(|b| *b == 0xFF) {
222        node.children.push(DissectionNode::new(
223            "mrt.bgp4mp.zebra_compat",
224            "Missing interface/AFI/address fields (old Zebra record)",
225            base + pos as u32,
226            0,
227        ));
228        node.children.push(dissect_bgp_message_base(
229            rest,
230            base + pos as u32,
231            &asn_len,
232            add_path,
233        ));
234        return node;
235    }
236
237    // Historical Zebra corruption also produced 8-byte state-change records
238    // with only the two 16-bit ASNs and the old/new states (see
239    // `parse_bgp4mp_state_change`); mirror that recognition here.
240    if !is_message && asn_size == 2 && data.len() == 8 {
241        let old_state = read_u16(data, pos).unwrap_or(0);
242        push_field(
243            &mut node,
244            "mrt.bgp4mp.old_state",
245            format!("Old state: {} ({})", old_state, bgp_state_name(old_state)),
246            base + pos as u32,
247            2,
248        );
249        let new_state = read_u16(data, pos + 2).unwrap_or(0);
250        push_field(
251            &mut node,
252            "mrt.bgp4mp.new_state",
253            format!("New state: {} ({})", new_state, bgp_state_name(new_state)),
254            base + pos as u32 + 2,
255            2,
256        );
257        return node;
258    }
259
260    // Shared subheader: interface index, AFI, and peer/local addresses.
261    let Some(pos) = walk_bgp4mp_addresses(&mut node, data, base, pos) else {
262        return node;
263    };
264
265    if is_message {
266        node.children.push(dissect_bgp_message_base(
267            &data[pos..],
268            base + pos as u32,
269            &asn_len,
270            add_path,
271        ));
272    } else {
273        // State change: old/new session states after the addresses
274        if data.len() < pos + 4 {
275            node.children.push(DissectionNode::new(
276                "mrt.bgp4mp.truncated",
277                "Truncated old/new states",
278                base + pos as u32,
279                (data.len() - pos) as u32,
280            ));
281            return node;
282        }
283        let old_state = read_u16(data, pos).unwrap_or(0);
284        push_field(
285            &mut node,
286            "mrt.bgp4mp.old_state",
287            format!("Old state: {} ({})", old_state, bgp_state_name(old_state)),
288            base + pos as u32,
289            2,
290        );
291        let new_state = read_u16(data, pos + 2).unwrap_or(0);
292        push_field(
293            &mut node,
294            "mrt.bgp4mp.new_state",
295            format!("New state: {} ({})", new_state, bgp_state_name(new_state)),
296            base + pos as u32 + 2,
297            2,
298        );
299    }
300
301    node
302}
303
304/// Walk the BGP4MP subheader fields shared by message and state-change
305/// records: interface index, AFI, and the peer/local addresses. Emits a
306/// truncation or unknown-AFI node and returns `None` when the walk cannot
307/// continue; otherwise returns the position after the addresses.
308fn walk_bgp4mp_addresses(
309    node: &mut DissectionNode,
310    data: &[u8],
311    base: u32,
312    mut pos: usize,
313) -> Option<usize> {
314    if data.len() < pos + 4 {
315        node.children.push(DissectionNode::new(
316            "mrt.bgp4mp.truncated",
317            "Truncated before interface index / AFI",
318            base + pos as u32,
319            (data.len() - pos) as u32,
320        ));
321        return None;
322    }
323    let afi = read_u16(data, pos + 2).unwrap_or(0);
324    push_field(
325        node,
326        "mrt.bgp4mp.interface_index",
327        format!("Interface index: {}", read_u16(data, pos).unwrap_or(0)),
328        base + pos as u32,
329        2,
330    );
331    pos += 2;
332    push_field(
333        node,
334        "mrt.bgp4mp.afi",
335        format!("Address family: {afi} ({})", afi_name(afi)),
336        base + pos as u32,
337        2,
338    );
339    pos += 2;
340    let addr_len = match afi {
341        1 => 4,
342        2 => 16,
343        _ => {
344            node.children.push(DissectionNode::new(
345                "mrt.bgp4mp.addresses",
346                format!("Unknown AFI {afi}; cannot walk further"),
347                base + pos as u32,
348                (data.len() - pos) as u32,
349            ));
350            return None;
351        }
352    };
353
354    if data.len() < pos + 2 * addr_len {
355        node.children.push(DissectionNode::new(
356            "mrt.bgp4mp.truncated",
357            "Truncated peer/local addresses",
358            base + pos as u32,
359            (data.len() - pos) as u32,
360        ));
361        return None;
362    }
363    push_field(
364        node,
365        "mrt.bgp4mp.peer_ip",
366        format!("Peer IP: {}", render_ip(&data[pos..pos + addr_len])),
367        base + pos as u32,
368        addr_len,
369    );
370    pos += addr_len;
371    push_field(
372        node,
373        "mrt.bgp4mp.local_ip",
374        format!("Local IP: {}", render_ip(&data[pos..pos + addr_len])),
375        base + pos as u32,
376        addr_len,
377    );
378    Some(pos + addr_len)
379}
380
381fn afi_name(afi: u16) -> &'static str {
382    match afi {
383        1 => "IPv4",
384        2 => "IPv6",
385        _ => "unknown",
386    }
387}
388
389fn push_field(node: &mut DissectionNode, field: &str, label: String, offset: u32, len: usize) {
390    node.children
391        .push(DissectionNode::new(field, label, offset, len as u32));
392}
393
394fn read_asn(data: &[u8], pos: usize, asn_size: usize) -> u32 {
395    if asn_size == 4 {
396        u32::from_be_bytes([
397            *data.get(pos).unwrap_or(&0),
398            *data.get(pos + 1).unwrap_or(&0),
399            *data.get(pos + 2).unwrap_or(&0),
400            *data.get(pos + 3).unwrap_or(&0),
401        ])
402    } else {
403        read_u16(data, pos).unwrap_or(0) as u32
404    }
405}
406
407fn render_ip(octets: &[u8]) -> String {
408    if octets.len() == 4 {
409        format!("{}.{}.{}.{}", octets[0], octets[1], octets[2], octets[3])
410    } else {
411        octets
412            .chunks(2)
413            .map(|c| format!("{:02x}{:02x}", c[0], c[1]))
414            .collect::<Vec<_>>()
415            .join(":")
416    }
417}
418
419fn bgp_state_name(state: u16) -> &'static str {
420    match state {
421        1 => "Idle",
422        2 => "Connect",
423        3 => "Active",
424        4 => "OpenSent",
425        5 => "OpenConfirm",
426        6 => "Established",
427        _ => "Unknown",
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    /// Build one BGP4MP_MESSAGE_AS4 MRT record with a minimal UPDATE inside,
436    /// returning the full record wire bytes.
437    fn bgp4mp_update_record() -> Vec<u8> {
438        // UPDATE body: no withdrawn, ORIGIN+AS_PATH+NEXT_HOP, one prefix
439        let mut attrs = Vec::new();
440        attrs.extend_from_slice(&[0x40, 0x01, 0x01, 0x00]);
441        attrs.extend_from_slice(&[0x40, 0x02, 0x06, 0x02, 0x01]);
442        attrs.extend_from_slice(&65001u32.to_be_bytes());
443        attrs.extend_from_slice(&[0x40, 0x03, 0x04, 192, 0, 2, 254]);
444        let mut update = Vec::new();
445        update.extend_from_slice(&0u16.to_be_bytes());
446        update.extend_from_slice(&(attrs.len() as u16).to_be_bytes());
447        update.extend_from_slice(&attrs);
448        update.extend_from_slice(&[24, 203, 0, 113]);
449
450        // BGP message with header
451        let mut bgp = vec![0xFF; 16];
452        bgp.extend_from_slice(&((19 + update.len()) as u16).to_be_bytes());
453        bgp.push(2);
454        bgp.extend_from_slice(&update);
455
456        // BGP4MP body
457        let mut body = Vec::new();
458        body.extend_from_slice(&64496u32.to_be_bytes());
459        body.extend_from_slice(&64497u32.to_be_bytes());
460        body.extend_from_slice(&0u16.to_be_bytes()); // interface index
461        body.extend_from_slice(&1u16.to_be_bytes()); // AFI IPv4
462        body.extend_from_slice(&[192, 0, 2, 1]); // peer IP
463        body.extend_from_slice(&[192, 0, 2, 2]); // local IP
464        body.extend_from_slice(&bgp);
465
466        // MRT common header: type 16, subtype 4 (BGP4MP_MESSAGE_AS4)
467        let mut wire = Vec::new();
468        wire.extend_from_slice(&1_700_000_000u32.to_be_bytes());
469        wire.extend_from_slice(&16u16.to_be_bytes());
470        wire.extend_from_slice(&4u16.to_be_bytes());
471        wire.extend_from_slice(&(body.len() as u32).to_be_bytes());
472        wire.extend_from_slice(&body);
473        wire
474    }
475
476    #[test]
477    fn dissect_mrt_record_layered_offsets() {
478        let wire = bgp4mp_update_record();
479        let tree = dissect_mrt_bytes(&wire);
480
481        assert_eq!(tree.field, "mrt");
482        assert_eq!(tree.length, wire.len() as u32);
483
484        let timestamp = tree.find("mrt.header.timestamp").unwrap();
485        assert_eq!((timestamp.offset, timestamp.length), (0, 4));
486        let entry_type = tree.find("mrt.header.type").unwrap();
487        assert_eq!(entry_type.label, "Type: BGP4MP (16)");
488        let subtype = tree.find("mrt.header.subtype").unwrap();
489        assert_eq!(subtype.label, "Subtype: 4");
490
491        // BGP4MP subheader at offset 12
492        let peer_asn = tree.find("mrt.bgp4mp.peer_asn").unwrap();
493        assert_eq!((peer_asn.offset, peer_asn.length), (12, 4));
494        assert_eq!(peer_asn.label, "Peer ASN: 64496");
495        let afi = tree.find("mrt.bgp4mp.afi").unwrap();
496        assert_eq!(afi.offset, 22);
497        assert_eq!(afi.label, "Address family: 1 (IPv4)");
498        let peer_ip = tree.find("mrt.bgp4mp.peer_ip").unwrap();
499        assert_eq!(peer_ip.label, "Peer IP: 192.0.2.1");
500
501        // Embedded BGP message shares the record coordinate space
502        let marker = tree.find("bgp.header.marker").unwrap();
503        assert_eq!(marker.offset, 12 + 20);
504        let segment = tree.find("bgp.attr.as_path.segment").unwrap();
505        assert_eq!(segment.label, "AS_SEQUENCE: 65001");
506        let prefix = tree.find("bgp.update.nlri").unwrap();
507        assert_eq!(prefix.offset + prefix.length, wire.len() as u32);
508    }
509
510    #[test]
511    fn dissect_mrt_record_from_raw() {
512        // chunk one record and dissect via the RawMrtRecord API
513        let wire = bgp4mp_update_record();
514        let mut cursor = std::io::Cursor::new(wire.clone());
515        let raw = crate::parser::mrt::chunk_mrt_record(&mut cursor).unwrap();
516        let tree = dissect_mrt_record(&raw);
517        assert_eq!(tree.length, wire.len() as u32);
518        assert!(tree.find("bgp.header.type").is_some());
519    }
520
521    #[test]
522    fn dissect_mrt_state_change() {
523        let mut body = Vec::new();
524        body.extend_from_slice(&64496u16.to_be_bytes());
525        body.extend_from_slice(&64497u16.to_be_bytes());
526        body.extend_from_slice(&0u16.to_be_bytes());
527        body.extend_from_slice(&1u16.to_be_bytes());
528        body.extend_from_slice(&[192, 0, 2, 1]);
529        body.extend_from_slice(&[192, 0, 2, 2]);
530        body.extend_from_slice(&5u16.to_be_bytes()); // OpenConfirm
531        body.extend_from_slice(&6u16.to_be_bytes()); // Established
532
533        let mut wire = Vec::new();
534        wire.extend_from_slice(&2u32.to_be_bytes());
535        wire.extend_from_slice(&16u16.to_be_bytes());
536        wire.extend_from_slice(&0u16.to_be_bytes()); // STATE_CHANGE
537        wire.extend_from_slice(&(body.len() as u32).to_be_bytes());
538        wire.extend_from_slice(&body);
539
540        let tree = dissect_mrt_bytes(&wire);
541        let new_state = tree.find("mrt.bgp4mp.new_state").unwrap();
542        assert_eq!(new_state.label, "New state: 6 (Established)");
543        assert_eq!(new_state.offset, wire.len() as u32 - 2);
544    }
545
546    #[test]
547    fn dissect_mrt_truncated_header_and_unknown_type() {
548        let partial = dissect_mrt_bytes(&[0, 0, 0, 1, 0]);
549        let header = partial.find("mrt.header").unwrap();
550        assert!(header.label.contains("truncated"));
551
552        // TABLE_DUMP_V2 record body stays opaque
553        let mut wire = Vec::new();
554        wire.extend_from_slice(&1u32.to_be_bytes());
555        wire.extend_from_slice(&13u16.to_be_bytes());
556        wire.extend_from_slice(&1u16.to_be_bytes());
557        wire.extend_from_slice(&4u32.to_be_bytes());
558        wire.extend_from_slice(&[0xAA; 4]);
559        let tree = dissect_mrt_bytes(&wire);
560        let body = tree.find("mrt.body").unwrap();
561        assert_eq!(body.label, "Message body (4 bytes)");
562    }
563
564    #[test]
565    fn dissect_mrt_et_record_microsecond_field() {
566        // BGP4MP_ET (type 17): the 4-byte microsecond field follows the
567        // common header (offset 12), then the BGP4MP subheader at offset 16.
568        let mut bgp = vec![0xFF; 16];
569        bgp.extend_from_slice(&19u16.to_be_bytes());
570        bgp.push(4); // KEEPALIVE
571
572        let mut body = Vec::new();
573        body.extend_from_slice(&64496u32.to_be_bytes()); // peer ASN
574        body.extend_from_slice(&64497u32.to_be_bytes()); // local ASN
575        body.extend_from_slice(&0u16.to_be_bytes()); // interface index
576        body.extend_from_slice(&1u16.to_be_bytes()); // AFI IPv4
577        body.extend_from_slice(&[192, 0, 2, 1]);
578        body.extend_from_slice(&[192, 0, 2, 2]);
579        body.extend_from_slice(&bgp);
580
581        let mut wire = Vec::new();
582        wire.extend_from_slice(&1_700_000_000u32.to_be_bytes());
583        wire.extend_from_slice(&17u16.to_be_bytes());
584        wire.extend_from_slice(&4u16.to_be_bytes()); // MESSAGE_AS4
585        wire.extend_from_slice(&(body.len() as u32).to_be_bytes());
586        wire.extend_from_slice(&123_456u32.to_be_bytes()); // micros at offset 12
587        wire.extend_from_slice(&body);
588
589        let tree = dissect_mrt_bytes(&wire);
590        let micro = tree.find("mrt.header.microsecond").unwrap();
591        assert_eq!((micro.offset, micro.length), (12, 4));
592        assert_eq!(micro.label, "Microsecond timestamp: 123456");
593        // body starts after the 16-byte extended header
594        let peer_asn = tree.find("mrt.bgp4mp.peer_asn").unwrap();
595        assert_eq!(peer_asn.offset, 16);
596        assert!(tree.find("bgp.header.type").is_some());
597    }
598
599    #[test]
600    fn dissect_mrt_et_record_truncated_microsecond() {
601        // ET record cut between 12 and 16 bytes: the partial microsecond
602        // field stays a truncated header node, never body bytes.
603        let mut wire = Vec::new();
604        wire.extend_from_slice(&1u32.to_be_bytes());
605        wire.extend_from_slice(&17u16.to_be_bytes());
606        wire.extend_from_slice(&4u16.to_be_bytes());
607        wire.extend_from_slice(&8u32.to_be_bytes());
608        wire.extend_from_slice(&[0xAB, 0xCD]); // 2 of 4 microsecond bytes
609
610        let tree = dissect_mrt_bytes(&wire);
611        let micro = tree.find("mrt.header.microsecond").unwrap();
612        assert_eq!(
613            micro.label,
614            "Microsecond timestamp (truncated: 2 of 4 bytes)"
615        );
616        assert_eq!((micro.offset, micro.length), (12, 2));
617        assert!(
618            tree.find("mrt.bgp4mp").is_none(),
619            "no body walk on truncated ET header"
620        );
621    }
622
623    #[test]
624    fn dissect_mrt_zebra_short_state_change() {
625        // Historical Zebra corruption: 8-byte state-change record with only
626        // the two 16-bit ASNs and old/new states.
627        let mut wire = Vec::new();
628        wire.extend_from_slice(&1u32.to_be_bytes());
629        wire.extend_from_slice(&16u16.to_be_bytes());
630        wire.extend_from_slice(&0u16.to_be_bytes()); // STATE_CHANGE
631        wire.extend_from_slice(&8u32.to_be_bytes());
632        wire.extend_from_slice(&64496u16.to_be_bytes());
633        wire.extend_from_slice(&64497u16.to_be_bytes());
634        wire.extend_from_slice(&5u16.to_be_bytes()); // OpenConfirm
635        wire.extend_from_slice(&6u16.to_be_bytes()); // Established
636
637        let tree = dissect_mrt_bytes(&wire);
638        let old_state = tree.find("mrt.bgp4mp.old_state").unwrap();
639        assert_eq!(old_state.label, "Old state: 5 (OpenConfirm)");
640        let new_state = tree.find("mrt.bgp4mp.new_state").unwrap();
641        assert_eq!(new_state.label, "New state: 6 (Established)");
642        assert_eq!(new_state.offset, wire.len() as u32 - 2);
643    }
644
645    #[test]
646    fn dissect_mrt_bgp4mp_ipv6_addresses() {
647        let mut bgp = vec![0xFF; 16];
648        bgp.extend_from_slice(&19u16.to_be_bytes());
649        bgp.push(4); // KEEPALIVE
650
651        let mut body = Vec::new();
652        body.extend_from_slice(&64496u32.to_be_bytes());
653        body.extend_from_slice(&64497u32.to_be_bytes());
654        body.extend_from_slice(&0u16.to_be_bytes());
655        body.extend_from_slice(&2u16.to_be_bytes()); // AFI IPv6
656        body.extend_from_slice(&[0x20, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
657        body.extend_from_slice(&[0x20, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]);
658        body.extend_from_slice(&bgp);
659
660        let mut wire = Vec::new();
661        wire.extend_from_slice(&1u32.to_be_bytes());
662        wire.extend_from_slice(&16u16.to_be_bytes());
663        wire.extend_from_slice(&4u16.to_be_bytes());
664        wire.extend_from_slice(&(body.len() as u32).to_be_bytes());
665        wire.extend_from_slice(&body);
666
667        let tree = dissect_mrt_bytes(&wire);
668        let afi = tree.find("mrt.bgp4mp.afi").unwrap();
669        assert_eq!(afi.label, "Address family: 2 (IPv6)");
670        let peer_ip = tree.find("mrt.bgp4mp.peer_ip").unwrap();
671        assert_eq!(peer_ip.length, 16);
672        assert_eq!(
673            peer_ip.label,
674            "Peer IP: 2001:0000:0000:0000:0000:0000:0000:0001"
675        );
676        // BGP message begins right after both 16-byte addresses
677        let marker = tree.find("bgp.header.marker").unwrap();
678        assert_eq!(marker.offset, 12 + 4 + 4 + 2 + 2 + 32);
679    }
680
681    #[test]
682    fn dissect_mrt_zebra_compat_detected() {
683        // Two 16-bit ASNs followed directly by a BGP marker
684        let mut bgp = vec![0xFF; 16];
685        bgp.extend_from_slice(&19u16.to_be_bytes());
686        bgp.push(4); // KEEPALIVE
687
688        let mut body = Vec::new();
689        body.extend_from_slice(&64496u16.to_be_bytes());
690        body.extend_from_slice(&64497u16.to_be_bytes());
691        body.extend_from_slice(&bgp);
692
693        let mut wire = Vec::new();
694        wire.extend_from_slice(&1u32.to_be_bytes());
695        wire.extend_from_slice(&16u16.to_be_bytes());
696        wire.extend_from_slice(&1u16.to_be_bytes()); // BGP4MP_MESSAGE
697        wire.extend_from_slice(&(body.len() as u32).to_be_bytes());
698        wire.extend_from_slice(&body);
699
700        let tree = dissect_mrt_bytes(&wire);
701        assert!(tree.find("mrt.bgp4mp.zebra_compat").is_some());
702        assert!(tree.find("bgp.header.type").is_some());
703    }
704}