Skip to main content

netget/server/ndp/
codec.rs

1//! Pure ICMPv6 Neighbour Discovery (RFC 4861) codec.
2//!
3//! Everything in this file is a **pure function over plain values**: it opens no socket, reads no
4//! configuration and touches no global state. That is the whole design. NDP's real transport is a
5//! raw ICMPv6 socket, which needs root or `CAP_NET_RAW`, and nothing in this repository has that —
6//! so the transport can never be executed here, but the packet format can be, and is, checked
7//! against literal bytes in `tests/server/ndp/codec_test.rs`.
8//!
9//! This is the `bluetooth_ble_beacon` split the root `CLAUDE.md` describes: message construction
10//! is pure and exhaustively tested; the platform transport is a thin layer over it that has never
11//! run. `metadata().notes` says both.
12//!
13//! # Message layouts (RFC 4861 §4)
14//!
15//! Every one starts with the ICMPv6 header of RFC 4443 §2.1 — type, code, checksum — and every
16//! one of the five NDP types uses code 0. Options, when present, always follow.
17//!
18//! ```text
19//! Router Solicitation  (133)  type code cksum | reserved(4)                          | options
20//! Router Advertisement (134)  type code cksum | hop(1) flags(1) lifetime(2)
21//!                                             | reachable(4) | retrans(4)            | options
22//! Neighbour Solicit.   (135)  type code cksum | reserved(4) | target(16)             | options
23//! Neighbour Advert.    (136)  type code cksum | flags(4)    | target(16)             | options
24//! Redirect             (137)  type code cksum | reserved(4) | target(16) | dest(16)  | options
25//! ```
26//!
27//! # Two things implementations get wrong, both pinned by literal-byte tests
28//!
29//! 1. **Option length is in units of 8 octets, not octets** (RFC 4861 §4.6). A Prefix Information
30//!    option is 32 octets on the wire and its length field reads `4`. Writing `32` there produces
31//!    an option a real stack walks straight past the end of, and it is the classic NDP bug.
32//! 2. **The ICMPv6 checksum covers an IPv6 pseudo-header** (RFC 4443 §2.3, RFC 8200 §8.1) — the
33//!    source address, the destination address, the upper-layer length and next header 58. It is
34//!    therefore *not computable from the ICMPv6 bytes alone*, which is why every encode entry
35//!    point on this type demands both addresses. A codec that computes it over the message only
36//!    produces packets every conforming receiver silently drops.
37//!
38//! # No bytes cross the LLM boundary
39//!
40//! Addresses go in and out as IPv6 strings, link-layer addresses as `"00:11:22:33:44:55"`,
41//! flags as booleans, lifetimes as seconds. There is no way to hand this codec a hex blob and
42//! there is deliberately no action parameter that would accept one.
43
44use anyhow::{anyhow, bail, Context, Result};
45use serde_json::{json, Map, Value};
46use std::net::Ipv6Addr;
47
48// ---------------------------------------------------------------------------------------------
49// Wire constants
50// ---------------------------------------------------------------------------------------------
51
52/// IPv6 "Next Header" value for ICMPv6 (RFC 4443 §1). The pseudo-header carries this, not 0.
53pub const NEXT_HEADER_ICMPV6: u8 = 58;
54
55pub const TYPE_ROUTER_SOLICITATION: u8 = 133;
56pub const TYPE_ROUTER_ADVERTISEMENT: u8 = 134;
57pub const TYPE_NEIGHBOR_SOLICITATION: u8 = 135;
58pub const TYPE_NEIGHBOR_ADVERTISEMENT: u8 = 136;
59pub const TYPE_REDIRECT: u8 = 137;
60
61/// RFC 4861 §4: all five NDP messages use ICMPv6 code 0, and a receiver discards anything else.
62pub const NDP_CODE: u8 = 0;
63
64/// Option types (RFC 4861 §4.6, RFC 8106 §5.1 for RDNSS).
65pub const OPT_SOURCE_LINK_LAYER_ADDRESS: u8 = 1;
66pub const OPT_TARGET_LINK_LAYER_ADDRESS: u8 = 2;
67pub const OPT_PREFIX_INFORMATION: u8 = 3;
68pub const OPT_MTU: u8 = 5;
69pub const OPT_RDNSS: u8 = 25;
70
71/// Prefix Information flags (RFC 4861 §4.6.2).
72pub const PREFIX_FLAG_ON_LINK: u8 = 0x80;
73pub const PREFIX_FLAG_AUTONOMOUS: u8 = 0x40;
74
75/// Router Advertisement flags (RFC 4861 §4.2).
76pub const RA_FLAG_MANAGED: u8 = 0x80;
77pub const RA_FLAG_OTHER: u8 = 0x40;
78
79/// Neighbour Advertisement flags (RFC 4861 §4.4) — the top three bits of a 32-bit field.
80pub const NA_FLAG_ROUTER: u32 = 0x8000_0000;
81pub const NA_FLAG_SOLICITED: u32 = 0x4000_0000;
82pub const NA_FLAG_OVERRIDE: u32 = 0x2000_0000;
83
84/// `ff02::1`, the all-nodes link-local multicast group: where an unsolicited Router
85/// Advertisement goes.
86pub const ALL_NODES_MULTICAST: Ipv6Addr = Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1);
87/// `ff02::2`, the all-routers link-local multicast group: where a Router Solicitation goes.
88pub const ALL_ROUTERS_MULTICAST: Ipv6Addr = Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 2);
89
90/// RFC 4861 §11.2: every NDP message is sent with an IPv6 Hop Limit of 255 and a receiver
91/// discards one that arrives with anything less. That is the protocol's entire defence against
92/// an off-link attacker — it cannot be forged, because a router decrements.
93pub const NDP_HOP_LIMIT: u32 = 255;
94
95/// A lifetime of `0xffffffff` means "for ever" in a Prefix Information option and in RDNSS.
96pub const LIFETIME_INFINITE: u32 = 0xffff_ffff;
97
98// ---------------------------------------------------------------------------------------------
99// Link-layer addresses
100// ---------------------------------------------------------------------------------------------
101
102/// Parse `aa:bb:cc:dd:ee:ff` (also accepting `-`, `.` and space separators) into six octets.
103pub fn parse_mac(text: &str) -> Result<[u8; 6]> {
104    let cleaned: String = text
105        .chars()
106        .filter(|c| !matches!(c, ':' | '-' | '.' | ' '))
107        .collect();
108    if cleaned.len() != 12 || !cleaned.chars().all(|c| c.is_ascii_hexdigit()) {
109        bail!(
110            "'{text}' is not a link-layer address (expected 6 hex octets, e.g. 00:11:22:33:44:55)"
111        );
112    }
113    let mut out = [0u8; 6];
114    for (i, byte) in out.iter_mut().enumerate() {
115        *byte = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16)
116            .with_context(|| format!("'{text}' is not a link-layer address"))?;
117    }
118    Ok(out)
119}
120
121/// Render six octets as lowercase colon-separated hex.
122pub fn format_mac(mac: &[u8; 6]) -> String {
123    format!(
124        "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
125        mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]
126    )
127}
128
129/// The solicited-node multicast address for a target (RFC 4291 §2.7.1):
130/// `ff02::1:ff00:0/104` with the target's low 24 bits appended.
131///
132/// This is where a Neighbour Solicitation for an address one does not yet know is sent, and it
133/// is the reason NDP does not need broadcast the way ARP does.
134pub fn solicited_node_multicast(target: Ipv6Addr) -> Ipv6Addr {
135    let o = target.octets();
136    Ipv6Addr::from([
137        0xff, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01, 0xff, o[13], o[14], o[15],
138    ])
139}
140
141// ---------------------------------------------------------------------------------------------
142// The checksum, and its pseudo-header
143// ---------------------------------------------------------------------------------------------
144
145/// The ICMPv6 checksum over the IPv6 pseudo-header of RFC 8200 §8.1 plus `message`.
146///
147/// The pseudo-header is 40 octets and is **never transmitted**:
148///
149/// ```text
150/// | source address (16) | destination address (16) | upper-layer length (4) | zero(3) | 58 |
151/// ```
152///
153/// `message` must already carry a zeroed checksum field (octets 2..4); this function does not
154/// mutate it. The result is the one's-complement of the one's-complement sum, in host order —
155/// [`write_checksum`] puts it on the wire.
156///
157/// **This is the single thing most ICMPv6 implementations get wrong**, in one of two ways:
158/// omitting the pseudo-header entirely (a checksum that is correct for the payload and wrong for
159/// every packet), or using next header 0 instead of 58 because the IPv6 header the packet is
160/// eventually wrapped in says something else. Both produce packets a conforming receiver drops
161/// without a word.
162pub fn icmpv6_checksum(source: Ipv6Addr, destination: Ipv6Addr, message: &[u8]) -> u16 {
163    let mut sum: u32 = 0;
164
165    let mut add_pair = |hi: u8, lo: u8| {
166        sum += ((hi as u32) << 8) | lo as u32;
167    };
168
169    for chunk in source.octets().chunks(2) {
170        add_pair(chunk[0], chunk[1]);
171    }
172    for chunk in destination.octets().chunks(2) {
173        add_pair(chunk[0], chunk[1]);
174    }
175
176    // Upper-layer packet length, 32 bits. NDP messages are far below 64KiB, but the field is
177    // four octets and both halves are summed.
178    let length = message.len() as u32;
179    add_pair((length >> 24) as u8, (length >> 16) as u8);
180    add_pair((length >> 8) as u8, length as u8);
181
182    // Three octets of zero, then the next header. The zeros contribute nothing; the 58 lands in
183    // the low half of the last 16-bit word.
184    add_pair(0, NEXT_HEADER_ICMPV6);
185
186    let mut chunks = message.chunks_exact(2);
187    for chunk in chunks.by_ref() {
188        add_pair(chunk[0], chunk[1]);
189    }
190    // An odd trailing octet is padded on the right with zero (RFC 1071 §1).
191    if let [last] = chunks.remainder() {
192        add_pair(*last, 0);
193    }
194
195    while sum >> 16 != 0 {
196        sum = (sum & 0xffff) + (sum >> 16);
197    }
198    !(sum as u16)
199}
200
201/// Compute the checksum for `message` and write it into its header.
202///
203/// Zeroes the field first, so this is safe to call on a message that already carries one.
204pub fn write_checksum(message: &mut [u8], source: Ipv6Addr, destination: Ipv6Addr) -> Result<u16> {
205    if message.len() < 4 {
206        bail!(
207            "an ICMPv6 message is at least 4 octets (type, code, checksum); got {}",
208            message.len()
209        );
210    }
211    message[2] = 0;
212    message[3] = 0;
213    let checksum = icmpv6_checksum(source, destination, message);
214    message[2..4].copy_from_slice(&checksum.to_be_bytes());
215    Ok(checksum)
216}
217
218/// Verify the checksum a message carries against the addresses it was sent between.
219///
220/// Separate from [`decode`] deliberately: on a raw ICMPv6 socket the kernel has already verified
221/// it and does not report the destination address without `IPV6_RECVPKTINFO`, so the transport
222/// there cannot call this. The UDP test transport carries both addresses and does.
223pub fn verify_checksum(message: &[u8], source: Ipv6Addr, destination: Ipv6Addr) -> Result<()> {
224    if message.len() < 4 {
225        bail!(
226            "ICMPv6 message is {} octets, too short to check",
227            message.len()
228        );
229    }
230    let carried = u16::from_be_bytes([message[2], message[3]]);
231    let mut zeroed = message.to_vec();
232    zeroed[2] = 0;
233    zeroed[3] = 0;
234    let expected = icmpv6_checksum(source, destination, &zeroed);
235    if carried != expected {
236        bail!(
237            "ICMPv6 checksum is 0x{carried:04x} but the pseudo-header over {source} -> \
238             {destination} gives 0x{expected:04x}"
239        );
240    }
241    Ok(())
242}
243
244// ---------------------------------------------------------------------------------------------
245// Options
246// ---------------------------------------------------------------------------------------------
247
248/// A Prefix Information option's contents (RFC 4861 §4.6.2).
249///
250/// This is the option that hands a host its address and its on-link determination, and the two
251/// flags are the ones that decide how much: `on_link` says "you can reach this prefix without a
252/// router", `autonomous` says "build yourself an address out of it".
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub struct PrefixInformation {
255    pub prefix: Ipv6Addr,
256    pub prefix_length: u8,
257    pub on_link: bool,
258    pub autonomous: bool,
259    pub valid_lifetime: u32,
260    pub preferred_lifetime: u32,
261}
262
263/// One Neighbour Discovery option.
264///
265/// [`NdpOption::Other`] deliberately keeps only the type and the declared length: an option this
266/// codec does not model still has to be walked over correctly, but putting its octets into event
267/// data would break the no-bytes-to-the-model rule for no benefit.
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum NdpOption {
270    SourceLinkLayerAddress([u8; 6]),
271    TargetLinkLayerAddress([u8; 6]),
272    PrefixInformation(PrefixInformation),
273    Mtu(u32),
274    /// RFC 8106 §5.1. One option carries a lifetime and one or more resolver addresses.
275    Rdnss {
276        lifetime: u32,
277        servers: Vec<Ipv6Addr>,
278    },
279    Other {
280        option_type: u8,
281        length_units: u8,
282    },
283}
284
285impl NdpOption {
286    pub fn option_type(&self) -> u8 {
287        match self {
288            NdpOption::SourceLinkLayerAddress(_) => OPT_SOURCE_LINK_LAYER_ADDRESS,
289            NdpOption::TargetLinkLayerAddress(_) => OPT_TARGET_LINK_LAYER_ADDRESS,
290            NdpOption::PrefixInformation(_) => OPT_PREFIX_INFORMATION,
291            NdpOption::Mtu(_) => OPT_MTU,
292            NdpOption::Rdnss { .. } => OPT_RDNSS,
293            NdpOption::Other { option_type, .. } => *option_type,
294        }
295    }
296
297    /// Serialise the option, header included.
298    ///
299    /// The length octet is **in units of 8 octets** (RFC 4861 §4.6), so it is the total length
300    /// divided by eight and never the octet count. Every option this codec produces is a whole
301    /// number of 8-octet units by construction, and the assertion at the end of this function
302    /// is what keeps that true if a variant is added later.
303    pub fn encode(&self) -> Result<Vec<u8>> {
304        let mut out = Vec::new();
305        match self {
306            NdpOption::SourceLinkLayerAddress(mac) | NdpOption::TargetLinkLayerAddress(mac) => {
307                // type | length=1 | 6 octets of IEEE 802 address = 8 octets total.
308                out.push(self.option_type());
309                out.push(1);
310                out.extend_from_slice(mac);
311            }
312            NdpOption::PrefixInformation(p) => {
313                if p.prefix_length > 128 {
314                    bail!(
315                        "prefix length {} is impossible; an IPv6 prefix is 0-128 bits",
316                        p.prefix_length
317                    );
318                }
319                // RFC 4861 §4.6.2 warns about exactly this combination: a prefix that a host is
320                // told to build an address from must leave 64 bits for the interface identifier.
321                if p.autonomous && p.prefix_length != 64 {
322                    bail!(
323                        "an autonomous prefix must be a /64 — SLAAC appends a 64-bit interface \
324                         identifier, so /{} leaves no room and every host would ignore it. Set \
325                         autonomous to false for a non-/64 prefix.",
326                        p.prefix_length
327                    );
328                }
329                if p.preferred_lifetime > p.valid_lifetime {
330                    bail!(
331                        "preferred_lifetime ({}) must not exceed valid_lifetime ({}); RFC 4861 \
332                         §4.6.2 requires a host to ignore the whole option when it does",
333                        p.preferred_lifetime,
334                        p.valid_lifetime
335                    );
336                }
337                out.push(OPT_PREFIX_INFORMATION);
338                out.push(4); // 32 octets / 8
339                out.push(p.prefix_length);
340                let mut flags = 0u8;
341                if p.on_link {
342                    flags |= PREFIX_FLAG_ON_LINK;
343                }
344                if p.autonomous {
345                    flags |= PREFIX_FLAG_AUTONOMOUS;
346                }
347                out.push(flags);
348                out.extend_from_slice(&p.valid_lifetime.to_be_bytes());
349                out.extend_from_slice(&p.preferred_lifetime.to_be_bytes());
350                out.extend_from_slice(&0u32.to_be_bytes()); // Reserved2
351                out.extend_from_slice(&p.prefix.octets());
352            }
353            NdpOption::Mtu(mtu) => {
354                out.push(OPT_MTU);
355                out.push(1); // 8 octets / 8
356                out.extend_from_slice(&0u16.to_be_bytes()); // Reserved
357                out.extend_from_slice(&mtu.to_be_bytes());
358            }
359            NdpOption::Rdnss { lifetime, servers } => {
360                if servers.is_empty() {
361                    bail!("an RDNSS option with no servers says nothing; omit it instead");
362                }
363                // 8 octets of header plus 16 per address => 1 + 2*n units.
364                let units = 1 + 2 * servers.len();
365                if units > u8::MAX as usize {
366                    bail!(
367                        "{} RDNSS servers need {units} 8-octet units, past the 255 the length \
368                         field can express",
369                        servers.len()
370                    );
371                }
372                out.push(OPT_RDNSS);
373                out.push(units as u8);
374                out.extend_from_slice(&0u16.to_be_bytes()); // Reserved
375                out.extend_from_slice(&lifetime.to_be_bytes());
376                for server in servers {
377                    out.extend_from_slice(&server.octets());
378                }
379            }
380            NdpOption::Other { option_type, .. } => {
381                bail!(
382                    "option type {option_type} was decoded but cannot be re-encoded: its contents \
383                     were deliberately not kept"
384                );
385            }
386        }
387
388        // The invariant the length field encodes. An option that is not a multiple of eight
389        // cannot be expressed at all, and shipping one would desynchronise every receiver's
390        // option walk from this point to the end of the packet.
391        if out.len() % 8 != 0 || out.is_empty() {
392            bail!(
393                "option type {} encoded to {} octets, which is not a whole number of 8-octet \
394                 units",
395                self.option_type(),
396                out.len()
397            );
398        }
399        Ok(out)
400    }
401
402    /// What the model is shown about one option: names, addresses and seconds. No octets.
403    pub fn to_event_data(&self) -> Value {
404        match self {
405            NdpOption::SourceLinkLayerAddress(mac) => json!({
406                "option": "source_link_layer_address",
407                "link_layer_address": format_mac(mac),
408            }),
409            NdpOption::TargetLinkLayerAddress(mac) => json!({
410                "option": "target_link_layer_address",
411                "link_layer_address": format_mac(mac),
412            }),
413            NdpOption::PrefixInformation(p) => json!({
414                "option": "prefix_information",
415                "prefix": p.prefix.to_string(),
416                "length": p.prefix_length,
417                "on_link": p.on_link,
418                "autonomous": p.autonomous,
419                "valid_lifetime": p.valid_lifetime,
420                "preferred_lifetime": p.preferred_lifetime,
421            }),
422            NdpOption::Mtu(mtu) => json!({"option": "mtu", "mtu": mtu}),
423            NdpOption::Rdnss { lifetime, servers } => json!({
424                "option": "rdnss",
425                "lifetime": lifetime,
426                "servers": servers.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
427            }),
428            NdpOption::Other {
429                option_type,
430                length_units,
431            } => json!({
432                "option": "unrecognised",
433                "option_type": option_type,
434                "length_units": length_units,
435            }),
436        }
437    }
438}
439
440/// Decode a run of options.
441///
442/// RFC 4861 §4.6: "Nodes MUST silently discard an ND packet that contains an option with length
443/// zero." That is not pedantry — a zero length is an infinite loop in a naive walker, and it is
444/// how a malicious neighbour wedges a stack.
445pub fn decode_options(mut data: &[u8]) -> Result<Vec<NdpOption>> {
446    let mut out = Vec::new();
447    while !data.is_empty() {
448        if data.len() < 2 {
449            bail!("option header is truncated: {} octet(s) remain", data.len());
450        }
451        let option_type = data[0];
452        let units = data[1];
453        if units == 0 {
454            bail!("option type {option_type} declares length 0, which RFC 4861 §4.6 forbids");
455        }
456        let total = units as usize * 8;
457        if total > data.len() {
458            bail!(
459                "option type {option_type} declares {units} 8-octet units ({total} octets) but \
460                 only {} remain",
461                data.len()
462            );
463        }
464        let body = &data[2..total];
465        data = &data[total..];
466
467        let option = match (option_type, units) {
468            (OPT_SOURCE_LINK_LAYER_ADDRESS, 1) => {
469                let mut mac = [0u8; 6];
470                mac.copy_from_slice(&body[..6]);
471                NdpOption::SourceLinkLayerAddress(mac)
472            }
473            (OPT_TARGET_LINK_LAYER_ADDRESS, 1) => {
474                let mut mac = [0u8; 6];
475                mac.copy_from_slice(&body[..6]);
476                NdpOption::TargetLinkLayerAddress(mac)
477            }
478            (OPT_PREFIX_INFORMATION, 4) => {
479                let mut prefix = [0u8; 16];
480                prefix.copy_from_slice(&body[14..30]);
481                NdpOption::PrefixInformation(PrefixInformation {
482                    prefix_length: body[0],
483                    on_link: body[1] & PREFIX_FLAG_ON_LINK != 0,
484                    autonomous: body[1] & PREFIX_FLAG_AUTONOMOUS != 0,
485                    valid_lifetime: u32::from_be_bytes([body[2], body[3], body[4], body[5]]),
486                    preferred_lifetime: u32::from_be_bytes([body[6], body[7], body[8], body[9]]),
487                    prefix: Ipv6Addr::from(prefix),
488                })
489            }
490            (OPT_MTU, 1) => {
491                NdpOption::Mtu(u32::from_be_bytes([body[2], body[3], body[4], body[5]]))
492            }
493            // 1 header unit + 2 units per address, so an even number of units cannot be RDNSS.
494            (OPT_RDNSS, units) if units >= 3 && units % 2 == 1 => {
495                let lifetime = u32::from_be_bytes([body[2], body[3], body[4], body[5]]);
496                let mut servers = Vec::new();
497                for chunk in body[6..].chunks_exact(16) {
498                    let mut addr = [0u8; 16];
499                    addr.copy_from_slice(chunk);
500                    servers.push(Ipv6Addr::from(addr));
501                }
502                NdpOption::Rdnss { lifetime, servers }
503            }
504            // A known type with an impossible length, or a type this codec does not model. Both
505            // are walked over rather than fatal: RFC 4861 §4.6 requires a receiver to ignore
506            // options it does not recognise, and a single malformed option should not lose an
507            // otherwise valid solicitation.
508            (option_type, length_units) => NdpOption::Other {
509                option_type,
510                length_units,
511            },
512        };
513        out.push(option);
514    }
515    Ok(out)
516}
517
518// ---------------------------------------------------------------------------------------------
519// Messages
520// ---------------------------------------------------------------------------------------------
521
522/// The body of a Router Advertisement (RFC 4861 §4.2).
523///
524/// This is the highest-impact message in IPv6. One of these hands a whole link its prefix, its
525/// default route and — through RDNSS — its DNS resolvers, and a host accepts it from anybody on
526/// the segment. See this module's `CLAUDE.md`.
527#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct RouterAdvertisement {
529    /// Hop Limit hosts should use. 0 means "unspecified, keep your own".
530    pub cur_hop_limit: u8,
531    /// "Managed address configuration": get your address from DHCPv6, not from a prefix.
532    pub managed: bool,
533    /// "Other configuration": get everything except the address from DHCPv6.
534    pub other: bool,
535    /// Seconds this router should be used as a default router. **0 means "I am not a router"**
536    /// and is how a router withdraws itself.
537    pub router_lifetime: u16,
538    /// Milliseconds a neighbour is assumed reachable after a reachability confirmation.
539    pub reachable_time: u32,
540    /// Milliseconds between retransmitted Neighbour Solicitations.
541    pub retrans_timer: u32,
542    pub options: Vec<NdpOption>,
543}
544
545/// One of the five Neighbour Discovery messages, decoded.
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub enum NdpMessage {
548    RouterSolicitation {
549        options: Vec<NdpOption>,
550    },
551    RouterAdvertisement(RouterAdvertisement),
552    NeighborSolicitation {
553        target: Ipv6Addr,
554        options: Vec<NdpOption>,
555    },
556    NeighborAdvertisement {
557        router: bool,
558        solicited: bool,
559        override_flag: bool,
560        target: Ipv6Addr,
561        options: Vec<NdpOption>,
562    },
563    Redirect {
564        /// A better first hop for `destination`, or `destination` itself when it is on-link.
565        target: Ipv6Addr,
566        destination: Ipv6Addr,
567        options: Vec<NdpOption>,
568    },
569}
570
571impl NdpMessage {
572    pub fn message_type(&self) -> u8 {
573        match self {
574            NdpMessage::RouterSolicitation { .. } => TYPE_ROUTER_SOLICITATION,
575            NdpMessage::RouterAdvertisement(_) => TYPE_ROUTER_ADVERTISEMENT,
576            NdpMessage::NeighborSolicitation { .. } => TYPE_NEIGHBOR_SOLICITATION,
577            NdpMessage::NeighborAdvertisement { .. } => TYPE_NEIGHBOR_ADVERTISEMENT,
578            NdpMessage::Redirect { .. } => TYPE_REDIRECT,
579        }
580    }
581
582    pub fn type_name(&self) -> &'static str {
583        match self {
584            NdpMessage::RouterSolicitation { .. } => "router_solicitation",
585            NdpMessage::RouterAdvertisement(_) => "router_advertisement",
586            NdpMessage::NeighborSolicitation { .. } => "neighbor_solicitation",
587            NdpMessage::NeighborAdvertisement { .. } => "neighbor_advertisement",
588            NdpMessage::Redirect { .. } => "redirect",
589        }
590    }
591
592    pub fn options(&self) -> &[NdpOption] {
593        match self {
594            NdpMessage::RouterSolicitation { options }
595            | NdpMessage::NeighborSolicitation { options, .. }
596            | NdpMessage::NeighborAdvertisement { options, .. }
597            | NdpMessage::Redirect { options, .. } => options,
598            NdpMessage::RouterAdvertisement(ra) => &ra.options,
599        }
600    }
601
602    fn options_mut(&mut self) -> &mut Vec<NdpOption> {
603        match self {
604            NdpMessage::RouterSolicitation { options }
605            | NdpMessage::NeighborSolicitation { options, .. }
606            | NdpMessage::NeighborAdvertisement { options, .. }
607            | NdpMessage::Redirect { options, .. } => options,
608            NdpMessage::RouterAdvertisement(ra) => &mut ra.options,
609        }
610    }
611
612    /// The Source Link-Layer Address option, if the sender included one.
613    pub fn source_link_layer(&self) -> Option<[u8; 6]> {
614        self.options().iter().find_map(|o| match o {
615            NdpOption::SourceLinkLayerAddress(mac) => Some(*mac),
616            _ => None,
617        })
618    }
619
620    /// The Target Link-Layer Address option, if the sender included one.
621    pub fn target_link_layer(&self) -> Option<[u8; 6]> {
622        self.options().iter().find_map(|o| match o {
623            NdpOption::TargetLinkLayerAddress(mac) => Some(*mac),
624            _ => None,
625        })
626    }
627
628    /// Serialise, with a correct checksum over the pseudo-header for `source` -> `destination`.
629    ///
630    /// Both addresses are required arguments rather than optional, because the checksum genuinely
631    /// cannot be computed without them and an API that let a caller forget would produce packets
632    /// every receiver drops.
633    pub fn encode(&self, source: Ipv6Addr, destination: Ipv6Addr) -> Result<Vec<u8>> {
634        let mut out = self.encode_without_checksum()?;
635        write_checksum(&mut out, source, destination)?;
636        Ok(out)
637    }
638
639    /// Serialise with the checksum field left as zero.
640    ///
641    /// Public because it is the honest thing to hand a raw ICMPv6 socket: RFC 3542 §3.1 makes the
642    /// kernel compute and insert the checksum for `IPPROTO_ICMPV6`, so anything written there is
643    /// overwritten. Also what the checksum tests build on.
644    pub fn encode_without_checksum(&self) -> Result<Vec<u8>> {
645        let mut out = Vec::with_capacity(64);
646        out.push(self.message_type());
647        out.push(NDP_CODE);
648        out.extend_from_slice(&[0, 0]); // Checksum, filled in later.
649
650        match self {
651            NdpMessage::RouterSolicitation { .. } => {
652                out.extend_from_slice(&0u32.to_be_bytes()); // Reserved
653            }
654            NdpMessage::RouterAdvertisement(ra) => {
655                out.push(ra.cur_hop_limit);
656                let mut flags = 0u8;
657                if ra.managed {
658                    flags |= RA_FLAG_MANAGED;
659                }
660                if ra.other {
661                    flags |= RA_FLAG_OTHER;
662                }
663                out.push(flags);
664                out.extend_from_slice(&ra.router_lifetime.to_be_bytes());
665                out.extend_from_slice(&ra.reachable_time.to_be_bytes());
666                out.extend_from_slice(&ra.retrans_timer.to_be_bytes());
667            }
668            NdpMessage::NeighborSolicitation { target, .. } => {
669                out.extend_from_slice(&0u32.to_be_bytes()); // Reserved
670                out.extend_from_slice(&target.octets());
671            }
672            NdpMessage::NeighborAdvertisement {
673                router,
674                solicited,
675                override_flag,
676                target,
677                ..
678            } => {
679                let mut flags = 0u32;
680                if *router {
681                    flags |= NA_FLAG_ROUTER;
682                }
683                if *solicited {
684                    flags |= NA_FLAG_SOLICITED;
685                }
686                if *override_flag {
687                    flags |= NA_FLAG_OVERRIDE;
688                }
689                out.extend_from_slice(&flags.to_be_bytes());
690                out.extend_from_slice(&target.octets());
691            }
692            NdpMessage::Redirect {
693                target,
694                destination,
695                ..
696            } => {
697                out.extend_from_slice(&0u32.to_be_bytes()); // Reserved
698                out.extend_from_slice(&target.octets());
699                out.extend_from_slice(&destination.octets());
700            }
701        }
702
703        for option in self.options() {
704            out.extend_from_slice(&option.encode()?);
705        }
706        Ok(out)
707    }
708
709    /// Parse an ICMPv6 message that is expected to be one of the five NDP types.
710    ///
711    /// The checksum is **not** checked here — see [`verify_checksum`] for why it cannot be, from
712    /// the ICMPv6 bytes alone.
713    pub fn decode(data: &[u8]) -> Result<Self> {
714        if data.len() < 4 {
715            bail!(
716                "ICMPv6 message is {} octet(s); the header alone is 4",
717                data.len()
718            );
719        }
720        let message_type = data[0];
721        let code = data[1];
722        if code != NDP_CODE {
723            bail!(
724                "ICMPv6 type {message_type} carries code {code}; RFC 4861 requires code 0 for \
725                 every Neighbour Discovery message and a receiver discards anything else"
726            );
727        }
728        let body = &data[4..];
729
730        let need = |want: usize, what: &str| -> Result<()> {
731            if body.len() < want {
732                bail!(
733                    "{what} needs {} octets after the ICMPv6 header, got {}",
734                    want,
735                    body.len()
736                );
737            }
738            Ok(())
739        };
740
741        let addr_at = |offset: usize| -> Ipv6Addr {
742            let mut octets = [0u8; 16];
743            octets.copy_from_slice(&body[offset..offset + 16]);
744            Ipv6Addr::from(octets)
745        };
746
747        match message_type {
748            TYPE_ROUTER_SOLICITATION => {
749                need(4, "a Router Solicitation")?;
750                Ok(NdpMessage::RouterSolicitation {
751                    options: decode_options(&body[4..])?,
752                })
753            }
754            TYPE_ROUTER_ADVERTISEMENT => {
755                need(12, "a Router Advertisement")?;
756                Ok(NdpMessage::RouterAdvertisement(RouterAdvertisement {
757                    cur_hop_limit: body[0],
758                    managed: body[1] & RA_FLAG_MANAGED != 0,
759                    other: body[1] & RA_FLAG_OTHER != 0,
760                    router_lifetime: u16::from_be_bytes([body[2], body[3]]),
761                    reachable_time: u32::from_be_bytes([body[4], body[5], body[6], body[7]]),
762                    retrans_timer: u32::from_be_bytes([body[8], body[9], body[10], body[11]]),
763                    options: decode_options(&body[12..])?,
764                }))
765            }
766            TYPE_NEIGHBOR_SOLICITATION => {
767                need(20, "a Neighbour Solicitation")?;
768                Ok(NdpMessage::NeighborSolicitation {
769                    target: addr_at(4),
770                    options: decode_options(&body[20..])?,
771                })
772            }
773            TYPE_NEIGHBOR_ADVERTISEMENT => {
774                need(20, "a Neighbour Advertisement")?;
775                let flags = u32::from_be_bytes([body[0], body[1], body[2], body[3]]);
776                Ok(NdpMessage::NeighborAdvertisement {
777                    router: flags & NA_FLAG_ROUTER != 0,
778                    solicited: flags & NA_FLAG_SOLICITED != 0,
779                    override_flag: flags & NA_FLAG_OVERRIDE != 0,
780                    target: addr_at(4),
781                    options: decode_options(&body[20..])?,
782                })
783            }
784            TYPE_REDIRECT => {
785                need(36, "a Redirect")?;
786                Ok(NdpMessage::Redirect {
787                    target: addr_at(4),
788                    destination: addr_at(20),
789                    options: decode_options(&body[36..])?,
790                })
791            }
792            other => bail!(
793                "ICMPv6 type {other} is not a Neighbour Discovery message (133 Router \
794                 Solicitation, 134 Router Advertisement, 135 Neighbour Solicitation, 136 \
795                 Neighbour Advertisement, 137 Redirect)"
796            ),
797        }
798    }
799
800    /// Everything the model is shown about a received message. Addresses as strings, flags as
801    /// booleans, lifetimes as numbers of seconds — never octets.
802    ///
803    /// Fields the message did not carry are **omitted rather than null**, so a script can test
804    /// presence with a plain `in`.
805    pub fn to_event_data(&self) -> Map<String, Value> {
806        let mut map = Map::new();
807        map.insert("message_type".into(), json!(self.type_name()));
808        map.insert("icmpv6_type".into(), json!(self.message_type()));
809
810        match self {
811            NdpMessage::RouterSolicitation { .. } => {}
812            NdpMessage::RouterAdvertisement(ra) => {
813                map.insert("cur_hop_limit".into(), json!(ra.cur_hop_limit));
814                map.insert("managed".into(), json!(ra.managed));
815                map.insert("other".into(), json!(ra.other));
816                map.insert("router_lifetime".into(), json!(ra.router_lifetime));
817                map.insert("reachable_time".into(), json!(ra.reachable_time));
818                map.insert("retrans_timer".into(), json!(ra.retrans_timer));
819
820                let prefixes: Vec<Value> = ra
821                    .options
822                    .iter()
823                    .filter_map(|o| match o {
824                        NdpOption::PrefixInformation(p) => Some(json!({
825                            "prefix": p.prefix.to_string(),
826                            "length": p.prefix_length,
827                            "on_link": p.on_link,
828                            "autonomous": p.autonomous,
829                            "valid_lifetime": p.valid_lifetime,
830                            "preferred_lifetime": p.preferred_lifetime,
831                        })),
832                        _ => None,
833                    })
834                    .collect();
835                if !prefixes.is_empty() {
836                    map.insert("prefixes".into(), json!(prefixes));
837                }
838                let rdnss: Vec<String> = ra
839                    .options
840                    .iter()
841                    .flat_map(|o| match o {
842                        NdpOption::Rdnss { servers, .. } => {
843                            servers.iter().map(|s| s.to_string()).collect()
844                        }
845                        _ => Vec::new(),
846                    })
847                    .collect();
848                if !rdnss.is_empty() {
849                    map.insert("rdnss".into(), json!(rdnss));
850                }
851                if let Some(mtu) = ra.options.iter().find_map(|o| match o {
852                    NdpOption::Mtu(m) => Some(*m),
853                    _ => None,
854                }) {
855                    map.insert("mtu".into(), json!(mtu));
856                }
857            }
858            NdpMessage::NeighborSolicitation { target, .. } => {
859                map.insert("target_address".into(), json!(target.to_string()));
860                map.insert(
861                    "solicited_node_multicast".into(),
862                    json!(solicited_node_multicast(*target).to_string()),
863                );
864            }
865            NdpMessage::NeighborAdvertisement {
866                router,
867                solicited,
868                override_flag,
869                target,
870                ..
871            } => {
872                map.insert("target_address".into(), json!(target.to_string()));
873                map.insert("router".into(), json!(router));
874                map.insert("solicited".into(), json!(solicited));
875                map.insert("override".into(), json!(override_flag));
876            }
877            NdpMessage::Redirect {
878                target,
879                destination,
880                ..
881            } => {
882                map.insert("target_address".into(), json!(target.to_string()));
883                map.insert("destination_address".into(), json!(destination.to_string()));
884            }
885        }
886
887        if let Some(mac) = self.source_link_layer() {
888            map.insert("source_link_layer".into(), json!(format_mac(&mac)));
889        }
890        if let Some(mac) = self.target_link_layer() {
891            map.insert("target_link_layer".into(), json!(format_mac(&mac)));
892        }
893        let options: Vec<Value> = self.options().iter().map(|o| o.to_event_data()).collect();
894        if !options.is_empty() {
895            map.insert("options".into(), json!(options));
896        }
897        map
898    }
899}
900
901// ---------------------------------------------------------------------------------------------
902// Action -> message
903// ---------------------------------------------------------------------------------------------
904
905/// A validated request to transmit one NDP message.
906///
907/// `source` and `destination` are optional because the *server* owns both: it knows its own
908/// link-local address and it knows who it is answering. A model naming them is impersonating a
909/// specific node deliberately, which is allowed and is exactly what makes this protocol
910/// interesting, but it must not be forced to.
911#[derive(Debug, Clone, PartialEq, Eq)]
912pub struct SendRequest {
913    pub message: NdpMessage,
914    pub source: Option<Ipv6Addr>,
915    pub destination: Option<Ipv6Addr>,
916}
917
918impl SendRequest {
919    /// Interpret one `send_*` action.
920    ///
921    /// Deliberately strict. Every message this builds writes something into the peer's stack —
922    /// a neighbour cache entry, a default route, a resolver list — so a field the model got wrong
923    /// must fail here, where the model can be told, rather than reaching a host half-formed.
924    pub fn from_action(action: &Value) -> Result<Self> {
925        let action_type = action
926            .get("type")
927            .and_then(Value::as_str)
928            .ok_or_else(|| anyhow!("action has no 'type'"))?;
929
930        let message = match action_type {
931            "send_router_advertisement" => Self::router_advertisement(action)?,
932            "send_neighbor_advertisement" => Self::neighbor_advertisement(action)?,
933            "send_neighbor_solicitation" => Self::neighbor_solicitation(action)?,
934            other => bail!("'{other}' does not describe an NDP message this server can transmit"),
935        };
936
937        // Encoding here is what makes this a validation rather than a hope: an autonomous prefix
938        // that is not a /64, a preferred lifetime past its valid lifetime, an RDNSS list too long
939        // for the length field — all fail now.
940        message.encode_without_checksum()?;
941
942        Ok(Self {
943            message,
944            source: optional_addr(action, "source_address")?,
945            destination: optional_addr(action, "destination_address")?,
946        })
947    }
948
949    /// Add a link-layer address option if the action named none.
950    ///
951    /// The server, not the model, is the authority on its own hardware address; RFC 4861 §4.4
952    /// requires the Target Link-Layer Address option on a solicited Neighbour Advertisement, and
953    /// §4.2 recommends the Source Link-Layer Address option on a Router Advertisement. Omitting
954    /// either yields a message a peer records incompletely and then has to solicit again for.
955    pub fn with_default_link_layer(mut self, mac: [u8; 6]) -> Self {
956        let wanted = match &self.message {
957            NdpMessage::NeighborAdvertisement { .. } => OPT_TARGET_LINK_LAYER_ADDRESS,
958            NdpMessage::RouterAdvertisement(_) | NdpMessage::NeighborSolicitation { .. } => {
959                OPT_SOURCE_LINK_LAYER_ADDRESS
960            }
961            _ => return self,
962        };
963        if self
964            .message
965            .options()
966            .iter()
967            .any(|o| o.option_type() == wanted)
968        {
969            return self;
970        }
971        let option = if wanted == OPT_TARGET_LINK_LAYER_ADDRESS {
972            NdpOption::TargetLinkLayerAddress(mac)
973        } else {
974            NdpOption::SourceLinkLayerAddress(mac)
975        };
976        self.message.options_mut().push(option);
977        self
978    }
979
980    fn router_advertisement(action: &Value) -> Result<NdpMessage> {
981        let mut options = Vec::new();
982
983        for (index, entry) in array_field(action, "prefixes")?.iter().enumerate() {
984            let prefix_text = entry
985                .get("prefix")
986                .and_then(Value::as_str)
987                .ok_or_else(|| anyhow!("prefixes[{index}] has no 'prefix'"))?;
988            // A model will reach for CIDR notation, and refusing it over a slash would be
989            // pointless pedantry — but the explicit `length` field wins when both are given.
990            let (base, slash_length) = match prefix_text.split_once('/') {
991                Some((addr, len)) => (
992                    addr,
993                    Some(len.trim().parse::<u8>().with_context(|| {
994                        format!(
995                            "prefixes[{index}] has an unreadable prefix length in '{prefix_text}'"
996                        )
997                    })?),
998                ),
999                None => (prefix_text, None),
1000            };
1001            let prefix = base.trim().parse::<Ipv6Addr>().with_context(|| {
1002                format!("prefixes[{index}] prefix '{base}' is not an IPv6 address")
1003            })?;
1004            let prefix_length = match entry.get("length") {
1005                Some(v) => u8::try_from(
1006                    v.as_u64()
1007                        .ok_or_else(|| anyhow!("prefixes[{index}].length must be a number"))?,
1008                )
1009                .map_err(|_| anyhow!("prefixes[{index}].length must be 0-128"))?,
1010                None => slash_length.unwrap_or(64),
1011            };
1012
1013            options.push(NdpOption::PrefixInformation(PrefixInformation {
1014                prefix,
1015                prefix_length,
1016                on_link: bool_field(entry, "on_link")?.unwrap_or(true),
1017                autonomous: bool_field(entry, "autonomous")?.unwrap_or(true),
1018                valid_lifetime: u32_field(entry, "valid_lifetime")?.unwrap_or(2_592_000),
1019                preferred_lifetime: u32_field(entry, "preferred_lifetime")?.unwrap_or(604_800),
1020            }));
1021        }
1022
1023        if let Some(mtu) = u32_field(action, "mtu")? {
1024            if mtu < 1280 {
1025                bail!("an MTU of {mtu} is below IPv6's minimum link MTU of 1280 (RFC 8200 §5)");
1026            }
1027            options.push(NdpOption::Mtu(mtu));
1028        }
1029
1030        let servers = array_field(action, "rdnss")?;
1031        if !servers.is_empty() {
1032            let mut resolved = Vec::with_capacity(servers.len());
1033            for (index, entry) in servers.iter().enumerate() {
1034                let text = entry
1035                    .as_str()
1036                    .ok_or_else(|| anyhow!("rdnss[{index}] must be an IPv6 address string"))?;
1037                resolved.push(
1038                    text.trim().parse::<Ipv6Addr>().with_context(|| {
1039                        format!("rdnss[{index}] '{text}' is not an IPv6 address")
1040                    })?,
1041                );
1042            }
1043            options.push(NdpOption::Rdnss {
1044                lifetime: u32_field(action, "rdnss_lifetime")?.unwrap_or(600),
1045                servers: resolved,
1046            });
1047        }
1048
1049        if let Some(mac) = mac_field(action, "source_link_layer")? {
1050            options.push(NdpOption::SourceLinkLayerAddress(mac));
1051        }
1052
1053        let router_lifetime = match u32_field(action, "router_lifetime")? {
1054            None => 1800,
1055            Some(v) => u16::try_from(v)
1056                .map_err(|_| anyhow!("router_lifetime is 16 bits: 0-65535 seconds, got {v}"))?,
1057        };
1058
1059        Ok(NdpMessage::RouterAdvertisement(RouterAdvertisement {
1060            cur_hop_limit: match u32_field(action, "hop_limit")? {
1061                None => 64,
1062                Some(v) => u8::try_from(v)
1063                    .map_err(|_| anyhow!("hop_limit is one octet: 0-255, got {v}"))?,
1064            },
1065            managed: bool_field(action, "managed")?.unwrap_or(false),
1066            other: bool_field(action, "other")?.unwrap_or(false),
1067            router_lifetime,
1068            reachable_time: u32_field(action, "reachable_time")?.unwrap_or(0),
1069            retrans_timer: u32_field(action, "retrans_timer")?.unwrap_or(0),
1070            options,
1071        }))
1072    }
1073
1074    fn neighbor_advertisement(action: &Value) -> Result<NdpMessage> {
1075        let target = required_addr(action, "target")?;
1076        let mut options = Vec::new();
1077        if let Some(mac) = mac_field(action, "target_link_layer")? {
1078            options.push(NdpOption::TargetLinkLayerAddress(mac));
1079        }
1080        Ok(NdpMessage::NeighborAdvertisement {
1081            router: bool_field(action, "router")?.unwrap_or(false),
1082            // Defaults chosen for the overwhelmingly common case: an advertisement sent because
1083            // somebody solicited it, carrying the authoritative answer.
1084            solicited: bool_field(action, "solicited")?.unwrap_or(true),
1085            override_flag: bool_field(action, "override")?.unwrap_or(true),
1086            target,
1087            options,
1088        })
1089    }
1090
1091    fn neighbor_solicitation(action: &Value) -> Result<NdpMessage> {
1092        let target = required_addr(action, "target")?;
1093        let mut options = Vec::new();
1094        if let Some(mac) = mac_field(action, "source_link_layer")? {
1095            options.push(NdpOption::SourceLinkLayerAddress(mac));
1096        }
1097        Ok(NdpMessage::NeighborSolicitation { target, options })
1098    }
1099
1100    /// Where this message goes when the action named no destination.
1101    ///
1102    /// `peer` is the source address of whatever provoked it, when there was one.
1103    pub fn default_destination(&self, peer: Option<Ipv6Addr>) -> Ipv6Addr {
1104        // An unspecified source (::) means the peer has no address yet — a node doing Duplicate
1105        // Address Detection. It cannot receive a unicast reply, so the answer is multicast.
1106        let peer = peer.filter(|p| !p.is_unspecified());
1107        match &self.message {
1108            NdpMessage::RouterAdvertisement(_) => peer.unwrap_or(ALL_NODES_MULTICAST),
1109            NdpMessage::NeighborAdvertisement { .. } => peer.unwrap_or(ALL_NODES_MULTICAST),
1110            // A solicitation is for an address we do not know the link-layer address of, so it
1111            // goes to the target's solicited-node group rather than to the peer.
1112            NdpMessage::NeighborSolicitation { target, .. } => solicited_node_multicast(*target),
1113            NdpMessage::RouterSolicitation { .. } => ALL_ROUTERS_MULTICAST,
1114            NdpMessage::Redirect { .. } => peer.unwrap_or(ALL_NODES_MULTICAST),
1115        }
1116    }
1117}
1118
1119// ---------------------------------------------------------------------------------------------
1120// Small typed field readers
1121// ---------------------------------------------------------------------------------------------
1122
1123fn required_addr(action: &Value, key: &str) -> Result<Ipv6Addr> {
1124    let text = action
1125        .get(key)
1126        .and_then(Value::as_str)
1127        .ok_or_else(|| anyhow!("'{key}' is required and must be an IPv6 address string"))?;
1128    text.trim()
1129        .parse::<Ipv6Addr>()
1130        .with_context(|| format!("'{key}' value '{text}' is not an IPv6 address"))
1131}
1132
1133fn optional_addr(action: &Value, key: &str) -> Result<Option<Ipv6Addr>> {
1134    match action.get(key) {
1135        None | Some(Value::Null) => Ok(None),
1136        Some(Value::String(s)) => {
1137            Ok(Some(s.trim().parse::<Ipv6Addr>().with_context(|| {
1138                format!("'{key}' value '{s}' is not an IPv6 address")
1139            })?))
1140        }
1141        Some(other) => bail!("'{key}' must be an IPv6 address string, got {other}"),
1142    }
1143}
1144
1145fn mac_field(action: &Value, key: &str) -> Result<Option<[u8; 6]>> {
1146    match action.get(key) {
1147        None | Some(Value::Null) => Ok(None),
1148        Some(Value::String(s)) => Ok(Some(parse_mac(s)?)),
1149        Some(other) => bail!("'{key}' must be a link-layer address string, got {other}"),
1150    }
1151}
1152
1153fn bool_field(action: &Value, key: &str) -> Result<Option<bool>> {
1154    match action.get(key) {
1155        None | Some(Value::Null) => Ok(None),
1156        Some(Value::Bool(b)) => Ok(Some(*b)),
1157        Some(other) => bail!("'{key}' must be true or false, got {other}"),
1158    }
1159}
1160
1161fn u32_field(action: &Value, key: &str) -> Result<Option<u32>> {
1162    match action.get(key) {
1163        None | Some(Value::Null) => Ok(None),
1164        Some(v) => {
1165            let n = v
1166                .as_u64()
1167                .ok_or_else(|| anyhow!("'{key}' must be a whole non-negative number, got {v}"))?;
1168            Ok(Some(u32::try_from(n).map_err(|_| {
1169                anyhow!("'{key}' must fit in 32 bits, got {n}")
1170            })?))
1171        }
1172    }
1173}
1174
1175fn array_field(action: &Value, key: &str) -> Result<Vec<Value>> {
1176    match action.get(key) {
1177        None | Some(Value::Null) => Ok(Vec::new()),
1178        Some(Value::Array(items)) => Ok(items.clone()),
1179        Some(other) => bail!("'{key}' must be an array, got {other}"),
1180    }
1181}
1182
1183// ---------------------------------------------------------------------------------------------
1184// The UDP test transport's framing
1185// ---------------------------------------------------------------------------------------------
1186
1187/// How many octets of addressing precede the ICMPv6 message on the UDP test transport.
1188pub const ADDRESSED_PREFIX_LEN: usize = 32;
1189
1190/// Wrap an ICMPv6 message with the two addresses its checksum was computed over.
1191///
1192/// The UDP test transport exists because a raw ICMPv6 socket needs privileges nothing here has.
1193/// It carries `source(16) || destination(16) || icmpv6 message`, which is exactly the part of
1194/// the IPv6 header the ICMPv6 checksum depends on — so the receiving side can verify the
1195/// checksum, and the test therefore proves the pseudo-header calculation end to end rather than
1196/// only in the codec's own unit tests.
1197///
1198/// **No real NDP peer speaks this.** It is not a tunnel or an encapsulation anyone has defined.
1199pub fn encode_addressed(source: Ipv6Addr, destination: Ipv6Addr, message: &[u8]) -> Vec<u8> {
1200    let mut out = Vec::with_capacity(ADDRESSED_PREFIX_LEN + message.len());
1201    out.extend_from_slice(&source.octets());
1202    out.extend_from_slice(&destination.octets());
1203    out.extend_from_slice(message);
1204    out
1205}
1206
1207/// The inverse of [`encode_addressed`].
1208pub fn decode_addressed(datagram: &[u8]) -> Result<(Ipv6Addr, Ipv6Addr, &[u8])> {
1209    if datagram.len() < ADDRESSED_PREFIX_LEN + 4 {
1210        bail!(
1211            "datagram is {} octets; the UDP test transport carries 16 octets of source address, \
1212             16 of destination and then at least a 4-octet ICMPv6 header",
1213            datagram.len()
1214        );
1215    }
1216    let mut source = [0u8; 16];
1217    source.copy_from_slice(&datagram[0..16]);
1218    let mut destination = [0u8; 16];
1219    destination.copy_from_slice(&datagram[16..32]);
1220    Ok((
1221        Ipv6Addr::from(source),
1222        Ipv6Addr::from(destination),
1223        &datagram[ADDRESSED_PREFIX_LEN..],
1224    ))
1225}