Skip to main content

sedsnet/
discovery.rs

1use alloc::collections::BTreeMap;
2use alloc::string::{String, ToString};
3use alloc::vec;
4use alloc::vec::Vec;
5
6#[cfg(feature = "std")]
7use crate::E2eEncryptionPolicy;
8#[cfg(feature = "std")]
9use crate::config::export_schema;
10use crate::router::encode_slice_le;
11use crate::{
12    DataEndpoint, DataType, MessageElement, TelemetryError, TelemetryResult,
13    config::{
14        OwnedDataTypeDefinition, OwnedEndpointDefinition, OwnedRuntimeSchemaSnapshot,
15        RuntimeSchemaSnapshot, e2e_encryption_policy_code, e2e_encryption_policy_from_code,
16        message_class_code, message_class_from_code, message_data_type_code,
17        message_data_type_from_code, reliable_code, reliable_from_code,
18    },
19    packet::Packet,
20};
21
22pub const DISCOVERY_ROUTE_TTL_MS: u64 = 30_000;
23pub const DISCOVERY_FAST_INTERVAL_MS: u64 = 250;
24pub const DISCOVERY_SLOW_INTERVAL_MS: u64 = 5_000;
25pub const DISCOVERY_SLOW_LINK_CAPACITY_BPS: u64 = 512;
26pub const DISCOVERY_SLOW_LINK_PING_INTERVAL_MS: u64 = 15_000;
27pub const DISCOVERY_SLOW_LINK_FULL_INTERVAL_MS: u64 = 120_000;
28/// Retry only links with an unreceived topology baseline, not healthy links.
29pub const DISCOVERY_BASELINE_RETRY_MS: u64 = 5_000;
30/// Let the address and full topology in a normal bootstrap arrive together.
31pub const DISCOVERY_BASELINE_GRACE_MS: u64 = 1_000;
32/// Number of compact topology advertisements emitted for each change. These
33/// packets are intentionally idempotent, so a small bounded repeat avoids a
34/// 120-second repair delay after one lossy control frame without resending the
35/// complete graph.
36pub const DISCOVERY_INCREMENTAL_RETRY_COUNT: u8 = 3;
37pub const TIMESYNC_SLOW_LINK_MIN_INTERVAL_MS: u64 = 30_000;
38// A zero-node full topology is never emitted. Reusing zero as the extension
39// marker makes older decoders reject the trailing delta payload cheaply
40// instead of interpreting a marker as an enormous allocation count.
41const DISCOVERY_TOPOLOGY_DELTA_MARKER: u32 = 0;
42
43/// Validate an untrusted wire count before reserving collection storage.
44///
45/// Every decoded element consumes at least `minimum_encoded_bytes` from the
46/// remaining payload. Checking that relationship before `Vec::with_capacity`
47/// prevents a corrupt count from reaching Rust's infallible capacity path and
48/// halting an embedded router with `capacity overflow`.
49fn bounded_wire_vec<T>(
50    payload: &[u8],
51    cursor: usize,
52    count: usize,
53    minimum_encoded_bytes: usize,
54    label: &'static str,
55) -> TelemetryResult<Vec<T>> {
56    let remaining = payload.len().saturating_sub(cursor);
57    if minimum_encoded_bytes == 0 || count > remaining / minimum_encoded_bytes {
58        return Err(TelemetryError::Unpack(label));
59    }
60    let mut values = Vec::new();
61    values
62        .try_reserve_exact(count)
63        .map_err(|_| TelemetryError::Unpack(label))?;
64    Ok(values)
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct DiscoveryCadenceState {
69    pub current_interval_ms: u64,
70    pub next_announce_ms: u64,
71}
72
73impl Default for DiscoveryCadenceState {
74    fn default() -> Self {
75        Self {
76            current_interval_ms: DISCOVERY_FAST_INTERVAL_MS,
77            next_announce_ms: 0,
78        }
79    }
80}
81
82impl DiscoveryCadenceState {
83    /// Switches discovery back to fast cadence and schedules an immediate announce.
84    pub fn on_topology_change(&mut self, now_ms: u64) {
85        self.current_interval_ms = DISCOVERY_FAST_INTERVAL_MS;
86        self.next_announce_ms = now_ms;
87    }
88
89    /// Advances the cadence after sending an announce, backing off toward the slow interval.
90    pub fn on_announce_sent(&mut self, now_ms: u64) {
91        self.next_announce_ms = now_ms.saturating_add(self.current_interval_ms);
92        self.current_interval_ms = core::cmp::min(
93            self.current_interval_ms.saturating_mul(2),
94            DISCOVERY_SLOW_INTERVAL_MS,
95        );
96    }
97
98    /// Returns `true` when discovery should emit another announce at `now_ms`.
99    pub fn due(&self, now_ms: u64) -> bool {
100        now_ms >= self.next_announce_ms
101    }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct TopologyBoardNode {
106    pub sender_id: String,
107    pub reachable_endpoints: Vec<DataEndpoint>,
108    pub reachable_timesync_sources: Vec<String>,
109    pub connections: Vec<String>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct TopologyLink {
114    pub source: String,
115    pub target: String,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct TopologyAnnouncerRoute {
120    pub sender_id: String,
121    pub reachable_endpoints: Vec<DataEndpoint>,
122    pub reachable_network_variables: Vec<DataType>,
123    pub reachable_timesync_sources: Vec<String>,
124    pub routers: Vec<TopologyBoardNode>,
125    pub last_seen_ms: u64,
126    pub age_ms: u64,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct TopologySideRoute {
131    pub side_id: usize,
132    pub side_name: String,
133    pub reachable_endpoints: Vec<DataEndpoint>,
134    pub reachable_network_variables: Vec<DataType>,
135    pub reachable_timesync_sources: Vec<String>,
136    pub announcers: Vec<TopologyAnnouncerRoute>,
137    pub last_seen_ms: u64,
138    pub age_ms: u64,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct TopologySnapshot {
143    pub advertised_endpoints: Vec<DataEndpoint>,
144    pub advertised_timesync_sources: Vec<String>,
145    pub routers: Vec<TopologyBoardNode>,
146    pub links: Vec<TopologyLink>,
147    pub routes: Vec<TopologySideRoute>,
148    pub current_announce_interval_ms: u64,
149    pub next_announce_ms: u64,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct ClientStatsSnapshot {
154    pub sender_id: String,
155    pub connected: bool,
156    pub side_ids: Vec<usize>,
157    pub side_names: Vec<String>,
158    pub last_seen_ms: Option<u64>,
159    pub age_ms: Option<u64>,
160    pub reachable_endpoints: Vec<DataEndpoint>,
161    pub reachable_timesync_sources: Vec<String>,
162    pub packets_sent: u64,
163    pub packets_received: u64,
164    pub bytes_sent: u64,
165    pub bytes_received: u64,
166}
167
168pub const LINK_CAPABILITY_HEADER_TEMPLATES: u32 = 0x0000_0001;
169pub const LINK_CAPABILITY_CHUNKING: u32 = 0x0000_0002;
170pub const LINK_CAPABILITY_RELIABILITY: u32 = 0x0000_0004;
171pub const LINK_CAPABILITY_CRYPTO: u32 = 0x0000_0008;
172pub const LINK_CAPABILITY_END_TO_END_RELIABILITY: u32 = 0x0000_0010;
173pub const LINK_CAPABILITY_OMIT_UNCHANGED_TIMESTAMPS: u32 = 0x0000_0020;
174
175pub const LINK_PROFILE_CANONICAL: u8 = 0;
176pub const LINK_PROFILE_TEMPLATE: u8 = 1;
177pub const LINK_PROFILE_IPV6_LIKE: u8 = 2;
178pub const LINK_PROFILE_IPV4_LIKE: u8 = 3;
179
180pub const ADDRESS_MODE_DYNAMIC: u8 = 0;
181pub const ADDRESS_MODE_REQUESTED: u8 = 1;
182pub const ADDRESS_MODE_STATIC: u8 = 2;
183pub const ADDRESS_STATE_REQUEST: u8 = 0;
184pub const ADDRESS_STATE_APPROVED: u8 = 1;
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct LinkCapabilities {
188    pub version: u8,
189    pub flags: u32,
190    pub profile: u8,
191    pub max_frame_bytes: u32,
192    pub compact_header_target_bytes: u32,
193    pub max_side_transport_templates: u32,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct AddressAdvertisement {
198    pub hostname: String,
199    pub address: u32,
200    pub requested_address: u32,
201    pub mode: u8,
202    pub state: u8,
203    pub birth_ms: u64,
204    pub owner_hash: u64,
205    pub reachable_endpoints: Vec<DataEndpoint>,
206    /// Network-variable data types available through this sender and the
207    /// routers behind it. This lets routing distinguish variables that share
208    /// a broad schema endpoint without falling back to link fanout.
209    pub reachable_network_variables: Vec<DataType>,
210    pub reachable_timesync_sources: Vec<String>,
211    pub link_capabilities: LinkCapabilities,
212}
213
214#[inline]
215pub const fn is_router_control_endpoint(ep: DataEndpoint) -> bool {
216    matches!(
217        ep,
218        DataEndpoint::TelemetryError | DataEndpoint::TimeSync | DataEndpoint::Discovery
219    )
220}
221
222/// Returns `true` when the endpoint is reserved for discovery control traffic.
223#[inline]
224pub const fn is_discovery_endpoint(ep: DataEndpoint) -> bool {
225    matches!(ep, DataEndpoint::Discovery)
226}
227
228/// Returns `true` when the data type is a discovery control packet type.
229#[inline]
230pub const fn is_discovery_type(ty: DataType) -> bool {
231    matches!(
232        ty,
233        DataType::DiscoveryAnnounce
234            | DataType::DiscoveryTimeSyncSources
235            | DataType::DiscoveryTopology
236            | DataType::DiscoverySchema
237            | DataType::DiscoveryTopologyRequest
238            | DataType::DiscoverySchemaRequest
239            | DataType::ManagedVariableRequest
240            | DataType::ManagedVariableValue
241            | DataType::DiscoveryLeave
242            | DataType::DiscoveryLinkCapabilities
243            | DataType::DiscoveryAddress
244    )
245}
246
247#[inline]
248pub const fn is_discovery_request_type(ty: DataType) -> bool {
249    matches!(
250        ty,
251        DataType::DiscoveryTopologyRequest
252            | DataType::DiscoverySchemaRequest
253            | DataType::ManagedVariableRequest
254            | DataType::DiscoveryLeave
255            | DataType::DiscoveryAddress
256    )
257}
258
259fn sort_dedup_strings(items: &mut Vec<String>) {
260    items.sort_unstable();
261    items.dedup();
262}
263
264/// Normalizes a topology-board list in place so it can be compared, exported, or encoded.
265pub fn normalize_topology_boards(boards: &mut Vec<TopologyBoardNode>) {
266    for board in boards.iter_mut() {
267        board
268            .reachable_endpoints
269            .retain(|ep| !is_router_control_endpoint(*ep));
270        crate::small_sort::sort(&mut board.reachable_endpoints);
271        board.reachable_endpoints.dedup();
272        sort_dedup_strings(&mut board.reachable_timesync_sources);
273        board.connections.retain(|peer| peer != &board.sender_id);
274        sort_dedup_strings(&mut board.connections);
275    }
276    boards.sort_unstable_by(|a, b| a.sender_id.cmp(&b.sender_id));
277    boards.dedup_by(|a, b| a.sender_id == b.sender_id);
278}
279
280pub fn topology_links_from_boards(boards: &[TopologyBoardNode]) -> Vec<TopologyLink> {
281    let mut links = Vec::new();
282    for board in boards {
283        for peer in &board.connections {
284            if peer == &board.sender_id {
285                continue;
286            }
287            let (source, target) = if board.sender_id <= *peer {
288                (board.sender_id.clone(), peer.clone())
289            } else {
290                (peer.clone(), board.sender_id.clone())
291            };
292            links.push(TopologyLink { source, target });
293        }
294    }
295    links.sort_unstable_by(|a, b| (&a.source, &a.target).cmp(&(&b.source, &b.target)));
296    links.dedup_by(|a, b| a.source == b.source && a.target == b.target);
297    links
298}
299
300/// Merges board topology views keyed by sender ID.
301pub fn merge_topology_boards(dst: &mut Vec<TopologyBoardNode>, src: &[TopologyBoardNode]) {
302    // Reuse retained nodes. Building a BTreeMap from cloned nodes briefly
303    // duplicated the complete topology (plus a second copy of each key),
304    // exhausting constrained bridges during incremental discovery updates.
305    // Preserve the old map's last-wins behavior for duplicate destination IDs.
306    let mut index = 0;
307    while index < dst.len() {
308        if dst[index + 1..]
309            .iter()
310            .any(|node| node.sender_id == dst[index].sender_id)
311        {
312            dst.remove(index);
313        } else {
314            index += 1;
315        }
316    }
317    for board in src {
318        if let Some(entry) = dst
319            .iter_mut()
320            .find(|node| node.sender_id == board.sender_id)
321        {
322            for endpoint in &board.reachable_endpoints {
323                if !entry.reachable_endpoints.contains(endpoint) {
324                    entry.reachable_endpoints.push(*endpoint);
325                }
326            }
327            for source in &board.reachable_timesync_sources {
328                if !entry.reachable_timesync_sources.contains(source) {
329                    entry.reachable_timesync_sources.push(source.clone());
330                }
331            }
332            for connection in &board.connections {
333                if !entry.connections.contains(connection) {
334                    entry.connections.push(connection.clone());
335                }
336            }
337        } else {
338            dst.push(board.clone());
339        }
340    }
341    normalize_topology_boards(dst);
342}
343
344/// Summarizes a board topology list into aggregated endpoint and time-source reachability.
345pub fn summarize_topology_boards(boards: &[TopologyBoardNode]) -> (Vec<DataEndpoint>, Vec<String>) {
346    let mut reachable_endpoints = Vec::new();
347    let mut reachable_timesync_sources = Vec::new();
348    for board in boards {
349        for endpoint in &board.reachable_endpoints {
350            if !is_router_control_endpoint(*endpoint) && !reachable_endpoints.contains(endpoint) {
351                reachable_endpoints.push(*endpoint);
352            }
353        }
354        for source in &board.reachable_timesync_sources {
355            if !reachable_timesync_sources.contains(source) {
356                reachable_timesync_sources.push(source.clone());
357            }
358        }
359    }
360    crate::small_sort::sort(&mut reachable_endpoints);
361    reachable_endpoints.dedup();
362    reachable_endpoints.retain(|ep| !is_router_control_endpoint(*ep));
363    sort_dedup_strings(&mut reachable_timesync_sources);
364    (reachable_endpoints, reachable_timesync_sources)
365}
366
367/// Builds a discovery announce packet advertising reachable non-discovery endpoints.
368pub fn build_discovery_announce(
369    sender: &str,
370    timestamp_ms: u64,
371    endpoints: &[DataEndpoint],
372) -> TelemetryResult<Packet> {
373    let payload_words: Vec<u32> = endpoints.iter().copied().map(|ep| ep.as_u32()).collect();
374    Packet::new(
375        DataType::DiscoveryAnnounce,
376        &[DataEndpoint::Discovery],
377        sender,
378        timestamp_ms,
379        encode_slice_le(payload_words.as_slice()),
380    )
381}
382
383/// Decodes a discovery announce packet into its advertised endpoints.
384pub fn decode_discovery_announce(pkt: &Packet) -> TelemetryResult<Vec<DataEndpoint>> {
385    if pkt.data_type() != DataType::DiscoveryAnnounce {
386        return Err(TelemetryError::InvalidType);
387    }
388    decode_discovery_payload(pkt.payload())
389}
390
391/// Decodes a discovery announce payload into a sorted, deduplicated endpoint list.
392pub fn decode_discovery_payload(payload: &[u8]) -> TelemetryResult<Vec<DataEndpoint>> {
393    if !payload.len().is_multiple_of(4) {
394        return Err(TelemetryError::Unpack("discovery payload width"));
395    }
396
397    let mut endpoints = Vec::with_capacity(payload.len() / 4);
398    for chunk in payload.as_chunks::<4>().0 {
399        let raw = u32::from_le_bytes(*chunk);
400        let ep = DataEndpoint(raw);
401        if is_discovery_endpoint(ep) {
402            continue;
403        }
404        endpoints.push(ep);
405    }
406    crate::small_sort::sort(&mut endpoints);
407    endpoints.dedup();
408    Ok(endpoints)
409}
410
411/// Builds a discovery packet advertising reachable time sync source identifiers.
412pub fn build_discovery_timesync_sources<S: AsRef<str>>(
413    sender: &str,
414    timestamp_ms: u64,
415    sources: &[S],
416) -> TelemetryResult<Packet> {
417    let mut payload = Vec::new();
418    let mut deduped: Vec<&str> = sources.iter().map(|s| s.as_ref()).collect();
419    crate::small_sort::sort(&mut deduped);
420    deduped.dedup();
421
422    payload.extend_from_slice(&(deduped.len() as u32).to_le_bytes());
423    for source in deduped {
424        let bytes = source.as_bytes();
425        let len = u32::try_from(bytes.len())
426            .map_err(|_| TelemetryError::Pack("discovery source id too long"))?;
427        payload.extend_from_slice(&len.to_le_bytes());
428        payload.extend_from_slice(bytes);
429    }
430
431    Packet::new(
432        DataType::DiscoveryTimeSyncSources,
433        &[DataEndpoint::Discovery],
434        sender,
435        timestamp_ms,
436        payload.into(),
437    )
438}
439
440pub fn build_discovery_topology_request(
441    sender: &str,
442    timestamp_ms: u64,
443) -> TelemetryResult<Packet> {
444    Packet::new(
445        DataType::DiscoveryTopologyRequest,
446        &[DataEndpoint::Discovery],
447        sender,
448        timestamp_ms,
449        Vec::<u8>::new().into(),
450    )
451}
452
453pub fn build_discovery_schema_request(sender: &str, timestamp_ms: u64) -> TelemetryResult<Packet> {
454    Packet::new(
455        DataType::DiscoverySchemaRequest,
456        &[DataEndpoint::Discovery],
457        sender,
458        timestamp_ms,
459        Vec::<u8>::new().into(),
460    )
461}
462
463pub fn build_discovery_leave(sender: &str, timestamp_ms: u64) -> TelemetryResult<Packet> {
464    Packet::new(
465        DataType::DiscoveryLeave,
466        &[DataEndpoint::Discovery],
467        sender,
468        timestamp_ms,
469        Vec::<u8>::new().into(),
470    )
471}
472
473pub fn build_discovery_link_capabilities(
474    sender: &str,
475    timestamp_ms: u64,
476    capabilities: LinkCapabilities,
477) -> TelemetryResult<Packet> {
478    let mut payload = Vec::with_capacity(18);
479    payload.push(capabilities.version);
480    payload.extend_from_slice(&capabilities.flags.to_le_bytes());
481    payload.push(capabilities.profile);
482    payload.extend_from_slice(&capabilities.max_frame_bytes.to_le_bytes());
483    payload.extend_from_slice(&capabilities.compact_header_target_bytes.to_le_bytes());
484    payload.extend_from_slice(&capabilities.max_side_transport_templates.to_le_bytes());
485    Packet::new(
486        DataType::DiscoveryLinkCapabilities,
487        &[DataEndpoint::Discovery],
488        sender,
489        timestamp_ms,
490        payload.into(),
491    )
492}
493
494pub fn decode_discovery_link_capabilities(pkt: &Packet) -> TelemetryResult<LinkCapabilities> {
495    if pkt.data_type() != DataType::DiscoveryLinkCapabilities {
496        return Err(TelemetryError::InvalidType);
497    }
498    let payload = pkt.payload();
499    if payload.len() != 18 {
500        return Err(TelemetryError::Unpack("discovery link capabilities width"));
501    }
502    Ok(LinkCapabilities {
503        version: payload[0],
504        flags: u32::from_le_bytes(payload[1..5].try_into().expect("4-byte flags")),
505        profile: payload[5],
506        max_frame_bytes: u32::from_le_bytes(payload[6..10].try_into().expect("4-byte max frame")),
507        compact_header_target_bytes: u32::from_le_bytes(
508            payload[10..14].try_into().expect("4-byte target"),
509        ),
510        max_side_transport_templates: u32::from_le_bytes(
511            payload[14..18].try_into().expect("4-byte templates"),
512        ),
513    })
514}
515
516pub fn build_discovery_address(
517    sender: &str,
518    timestamp_ms: u64,
519    ad: &AddressAdvertisement,
520) -> TelemetryResult<Packet> {
521    let mut payload = Vec::new();
522    payload.push(2);
523    payload.push(ad.mode);
524    payload.push(ad.state);
525    payload.extend_from_slice(&ad.address.to_le_bytes());
526    payload.extend_from_slice(&ad.requested_address.to_le_bytes());
527    payload.extend_from_slice(&ad.birth_ms.to_le_bytes());
528    payload.extend_from_slice(&ad.owner_hash.to_le_bytes());
529    encode_string(&mut payload, &ad.hostname)?;
530    let mut endpoints = ad.reachable_endpoints.clone();
531    endpoints.retain(|ep| !is_discovery_endpoint(*ep));
532    crate::small_sort::sort(&mut endpoints);
533    endpoints.dedup();
534    let endpoint_count = u32::try_from(endpoints.len())
535        .map_err(|_| TelemetryError::Pack("discovery address endpoint count"))?;
536    payload.extend_from_slice(&endpoint_count.to_le_bytes());
537    for ep in endpoints {
538        payload.extend_from_slice(&ep.as_u32().to_le_bytes());
539    }
540    let mut network_variables = ad.reachable_network_variables.clone();
541    crate::small_sort::sort(&mut network_variables);
542    network_variables.dedup();
543    let network_variable_count = u32::try_from(network_variables.len())
544        .map_err(|_| TelemetryError::Pack("discovery address network variable count"))?;
545    payload.extend_from_slice(&network_variable_count.to_le_bytes());
546    for ty in network_variables {
547        payload.extend_from_slice(&ty.as_u32().to_le_bytes());
548    }
549    let mut sources = ad.reachable_timesync_sources.clone();
550    sort_dedup_strings(&mut sources);
551    let source_count = u32::try_from(sources.len())
552        .map_err(|_| TelemetryError::Pack("discovery address source count"))?;
553    payload.extend_from_slice(&source_count.to_le_bytes());
554    for source in sources {
555        encode_string(&mut payload, &source)?;
556    }
557    payload.push(ad.link_capabilities.version);
558    payload.extend_from_slice(&ad.link_capabilities.flags.to_le_bytes());
559    payload.push(ad.link_capabilities.profile);
560    payload.extend_from_slice(&ad.link_capabilities.max_frame_bytes.to_le_bytes());
561    payload.extend_from_slice(
562        &ad.link_capabilities
563            .compact_header_target_bytes
564            .to_le_bytes(),
565    );
566    payload.extend_from_slice(
567        &ad.link_capabilities
568            .max_side_transport_templates
569            .to_le_bytes(),
570    );
571    Packet::new(
572        DataType::DiscoveryAddress,
573        &[DataEndpoint::Discovery],
574        sender,
575        timestamp_ms,
576        payload.into(),
577    )
578}
579
580pub fn decode_discovery_address(pkt: &Packet) -> TelemetryResult<AddressAdvertisement> {
581    if pkt.data_type() != DataType::DiscoveryAddress {
582        return Err(TelemetryError::InvalidType);
583    }
584    let payload = pkt.payload();
585    let mut cursor = 0usize;
586    let version = read_u8(payload, &mut cursor, "discovery address version")?;
587    if version != 1 && version != 2 {
588        return Err(TelemetryError::Unpack("discovery address version"));
589    }
590    let mode = read_u8(payload, &mut cursor, "discovery address mode")?;
591    let state = read_u8(payload, &mut cursor, "discovery address state")?;
592    let address = read_u32(payload, &mut cursor, "discovery address current")?;
593    let requested_address = read_u32(payload, &mut cursor, "discovery address requested")?;
594    if payload.len().saturating_sub(cursor) < 8 {
595        return Err(TelemetryError::Unpack("discovery address birth"));
596    }
597    let birth_ms = u64::from_le_bytes(
598        payload[cursor..cursor + 8]
599            .try_into()
600            .expect("8-byte birth"),
601    );
602    cursor += 8;
603    if payload.len().saturating_sub(cursor) < 8 {
604        return Err(TelemetryError::Unpack("discovery address owner"));
605    }
606    let owner_hash = u64::from_le_bytes(
607        payload[cursor..cursor + 8]
608            .try_into()
609            .expect("8-byte owner"),
610    );
611    cursor += 8;
612    let hostname = decode_string(payload, &mut cursor, "discovery address hostname")?;
613    let endpoint_count =
614        read_u32(payload, &mut cursor, "discovery address endpoint count")? as usize;
615    let mut reachable_endpoints = bounded_wire_vec(
616        payload,
617        cursor,
618        endpoint_count,
619        4,
620        "discovery address endpoint count",
621    )?;
622    for _ in 0..endpoint_count {
623        let raw = read_u32(payload, &mut cursor, "discovery address endpoint")?;
624        let ep = DataEndpoint(raw);
625        if !is_discovery_endpoint(ep) {
626            reachable_endpoints.push(ep);
627        }
628    }
629    crate::small_sort::sort(&mut reachable_endpoints);
630    reachable_endpoints.dedup();
631    let mut reachable_network_variables = Vec::new();
632    if version >= 2 {
633        let count = read_u32(
634            payload,
635            &mut cursor,
636            "discovery address network variable count",
637        )? as usize;
638        reachable_network_variables = bounded_wire_vec(
639            payload,
640            cursor,
641            count,
642            4,
643            "discovery address network variable count",
644        )?;
645        for _ in 0..count {
646            let raw = read_u32(payload, &mut cursor, "discovery address network variable")?;
647            /* A relay may hear the address advertisement before the matching
648             * runtime-schema fragment. Preserve the stable wire id so the
649             * route is usable immediately and the later schema merge can add
650             * its name/shape without requiring a blank-slate rediscovery. */
651            let ty = DataType(raw);
652            if !is_discovery_type(ty) {
653                reachable_network_variables.push(ty);
654            }
655        }
656        crate::small_sort::sort(&mut reachable_network_variables);
657        reachable_network_variables.dedup();
658    }
659    let source_count = read_u32(payload, &mut cursor, "discovery address source count")? as usize;
660    let mut reachable_timesync_sources = bounded_wire_vec(
661        payload,
662        cursor,
663        source_count,
664        4,
665        "discovery address source count",
666    )?;
667    for _ in 0..source_count {
668        let source = decode_string(payload, &mut cursor, "discovery address source")?;
669        if !source.is_empty() {
670            reachable_timesync_sources.push(source);
671        }
672    }
673    sort_dedup_strings(&mut reachable_timesync_sources);
674    let version = read_u8(payload, &mut cursor, "discovery address link version")?;
675    let flags = read_u32(payload, &mut cursor, "discovery address link flags")?;
676    let profile = read_u8(payload, &mut cursor, "discovery address link profile")?;
677    let max_frame_bytes = read_u32(payload, &mut cursor, "discovery address link max frame")?;
678    let compact_header_target_bytes = read_u32(
679        payload,
680        &mut cursor,
681        "discovery address link compact target",
682    )?;
683    let max_side_transport_templates =
684        read_u32(payload, &mut cursor, "discovery address link templates")?;
685    if cursor != payload.len() {
686        return Err(TelemetryError::Unpack("discovery address trailing bytes"));
687    }
688    if hostname.is_empty() || address == 0 {
689        return Err(TelemetryError::Unpack("bad discovery address"));
690    }
691    Ok(AddressAdvertisement {
692        hostname,
693        address,
694        requested_address,
695        mode,
696        state,
697        birth_ms,
698        owner_hash,
699        reachable_endpoints,
700        reachable_network_variables,
701        reachable_timesync_sources,
702        link_capabilities: LinkCapabilities {
703            version,
704            flags,
705            profile,
706            max_frame_bytes,
707            compact_header_target_bytes,
708            max_side_transport_templates,
709        },
710    })
711}
712
713pub fn build_managed_variable_request(
714    sender: &str,
715    timestamp_ms: u64,
716    ty: DataType,
717) -> TelemetryResult<Packet> {
718    Packet::new(
719        DataType::ManagedVariableRequest,
720        &[DataEndpoint::Discovery],
721        sender,
722        timestamp_ms,
723        encode_slice_le(&[ty.as_u32()]),
724    )
725}
726
727pub fn decode_managed_variable_request(pkt: &Packet) -> TelemetryResult<DataType> {
728    if pkt.data_type() != DataType::ManagedVariableRequest {
729        return Err(TelemetryError::InvalidType);
730    }
731    let payload = pkt.payload();
732    if payload.len() != 4 {
733        return Err(TelemetryError::Unpack("managed variable request width"));
734    }
735    let raw = u32::from_le_bytes(payload.try_into().expect("4-byte payload"));
736    /* Managed-variable requests are routable by their stable wire id. The
737     * relay itself need not have received that type's schema yet. */
738    Ok(DataType(raw))
739}
740
741/// Decodes a discovery time sync source packet into source identifiers.
742pub fn decode_discovery_timesync_sources(pkt: &Packet) -> TelemetryResult<Vec<String>> {
743    if pkt.data_type() != DataType::DiscoveryTimeSyncSources {
744        return Err(TelemetryError::InvalidType);
745    }
746    decode_discovery_timesync_sources_payload(pkt.payload())
747}
748
749/// Decodes a discovery time sync source payload into a sorted, deduplicated source list.
750pub fn decode_discovery_timesync_sources_payload(payload: &[u8]) -> TelemetryResult<Vec<String>> {
751    if payload.len() < 4 {
752        return Err(TelemetryError::Unpack("discovery timesync source count"));
753    }
754
755    let count = u32::from_le_bytes(payload[..4].try_into().expect("4-byte count")) as usize;
756    let mut cursor = 4usize;
757    let mut out = bounded_wire_vec(payload, cursor, count, 4, "discovery timesync source count")?;
758
759    for _ in 0..count {
760        if payload.len().saturating_sub(cursor) < 4 {
761            return Err(TelemetryError::Unpack("discovery timesync source len"));
762        }
763        let len = u32::from_le_bytes(payload[cursor..cursor + 4].try_into().expect("4-byte len"))
764            as usize;
765        cursor += 4;
766        if payload.len().saturating_sub(cursor) < len {
767            return Err(TelemetryError::Unpack("discovery timesync source bytes"));
768        }
769        let raw = &payload[cursor..cursor + len];
770        cursor += len;
771        let source = core::str::from_utf8(raw)
772            .map_err(|_| TelemetryError::Unpack("discovery timesync source utf8"))?;
773        if !source.is_empty() {
774            out.push(source.to_string());
775        }
776    }
777
778    if cursor != payload.len() {
779        return Err(TelemetryError::Unpack("discovery timesync trailing bytes"));
780    }
781
782    out.sort_unstable();
783    out.dedup();
784    Ok(out)
785}
786
787type TopologyScratchPeer<'a> = (
788    &'a str,
789    &'a [DataEndpoint],
790    &'a [String],
791    &'a [TopologyBoardNode],
792);
793
794/// Conservative scratch for the unique topology union, without constructing
795/// an owned graph. Include vector growth and the caller's concurrent copies.
796pub(crate) fn topology_snapshot_scratch<'a>(
797    sender: &str,
798    local_endpoints: usize,
799    variables: usize,
800    graph_copies: usize,
801    include_wire: bool,
802    peers: impl Iterator<Item = TopologyScratchPeer<'a>> + Clone,
803) -> (usize, usize) {
804    topology_snapshot_scratch_impl(
805        sender,
806        local_endpoints,
807        variables,
808        graph_copies,
809        include_wire,
810        &|index| peers.clone().nth(index),
811    )
812}
813
814#[inline(never)]
815fn topology_snapshot_scratch_impl<'a>(
816    sender: &str,
817    local_endpoints: usize,
818    variables: usize,
819    graph_copies: usize,
820    include_wire: bool,
821    peer_at: &dyn Fn(usize) -> Option<TopologyScratchPeer<'a>>,
822) -> (usize, usize) {
823    // One calculation body avoids duplicating nested iterator chains for every
824    // caller's routing-table iterator; access remains borrowed and allocation-free.
825    let peers = core::iter::from_fn({
826        let mut index = 0usize;
827        move || {
828            let peer = peer_at(index);
829            index = index.saturating_add(1);
830            peer
831        }
832    });
833    #[derive(Clone, Copy)]
834    struct View<'a> {
835        name: &'a str,
836        endpoints: &'a [DataEndpoint],
837        sources: &'a [String],
838        connections: &'a [String],
839        backlink: bool,
840    }
841    let node_size = core::mem::size_of::<TopologyBoardNode>();
842    let string_size = core::mem::size_of::<String>();
843    let mut nodes = 1usize;
844    let mut graph = node_size
845        .saturating_add(sender.len().saturating_mul(2))
846        .saturating_add(local_endpoints.saturating_add(1).saturating_mul(4))
847        .saturating_add(string_size);
848    let mut wire = 24usize
849        .saturating_add(sender.len().saturating_mul(2))
850        .saturating_add(local_endpoints.saturating_add(1).saturating_mul(4));
851    for (index, (name, _, _, _)) in peers.clone().enumerate() {
852        if name != sender && !peers.clone().take(index).any(|(old, _, _, _)| old == name) {
853            graph = graph.saturating_add(string_size).saturating_add(name.len());
854            wire = wire.saturating_add(4).saturating_add(name.len());
855        }
856    }
857    // Mirror the union used by merge_topology_boards without creating owned
858    // scratch. Overlapping peer catalogs retain each node and field only once.
859    let views = peers.flat_map(|(name, endpoints, sources, boards)| {
860        core::iter::once(View {
861            name,
862            endpoints,
863            sources,
864            connections: &[],
865            backlink: true,
866        })
867        .filter(move |_| boards.is_empty())
868        .chain(boards.iter().map(move |board| View {
869            name: &board.sender_id,
870            endpoints: &board.reachable_endpoints,
871            sources: &board.reachable_timesync_sources,
872            connections: &board.connections,
873            backlink: board.sender_id == name,
874        }))
875    });
876    for (index, node) in views.clone().enumerate() {
877        let prior = || {
878            views
879                .clone()
880                .take(index)
881                .filter(|old| old.name == node.name)
882        };
883        if node.name != sender && prior().next().is_none() {
884            nodes = nodes.saturating_add(1);
885            graph = graph
886                .saturating_add(node_size)
887                .saturating_add(node.name.len());
888            wire = wire.saturating_add(20).saturating_add(node.name.len());
889        }
890        for endpoint in node.endpoints {
891            if !prior().any(|old| old.endpoints.contains(endpoint)) {
892                graph = graph.saturating_add(4);
893                wire = wire.saturating_add(4);
894            }
895        }
896        for source in node.sources {
897            if !prior().any(|old| old.sources.contains(source)) {
898                graph = graph
899                    .saturating_add(string_size)
900                    .saturating_add(source.len());
901                wire = wire.saturating_add(4).saturating_add(source.len());
902            }
903        }
904        for connection in node.connections {
905            if connection != node.name
906                && !prior().any(|old| {
907                    old.connections.contains(connection) || (old.backlink && connection == sender)
908                })
909            {
910                graph = graph
911                    .saturating_add(string_size)
912                    .saturating_add(connection.len());
913                wire = wire.saturating_add(4).saturating_add(connection.len());
914            }
915        }
916        if node.backlink
917            && node.name != sender
918            && !node.connections.iter().any(|value| value == sender)
919            && !prior()
920                .any(|old| old.backlink || old.connections.iter().any(|value| value == sender))
921        {
922            graph = graph
923                .saturating_add(string_size)
924                .saturating_add(sender.len());
925            wire = wire.saturating_add(4).saturating_add(sender.len());
926        }
927    }
928    let grow = |bytes: usize| bytes.checked_next_power_of_two().unwrap_or(usize::MAX);
929    let largest = 1024usize
930        .max(grow(nodes).saturating_mul(node_size))
931        .max(if include_wire { grow(wire) } else { 0 });
932    // Election and per-side serialization run in separate phases. Account for
933    // the live graph copies in this phase, plus geometric vector growth;
934    // packet headers, statistics and queue bookkeeping have separate headroom.
935    let additional = 2048usize
936        .saturating_add(graph.saturating_mul(graph_copies))
937        .saturating_add(variables.saturating_mul(32));
938    (additional, largest)
939}
940
941// Advertisers already normalize their graphs. Borrow those buffers instead of
942// duplicating every node/string just to serialize them again. Public unsorted
943// inputs retain the original normalization behavior.
944#[inline(never)]
945fn topology_payload(
946    boards: &[TopologyBoardNode],
947    removed: Option<&[String]>,
948) -> TelemetryResult<Vec<u8>> {
949    let canonical = boards
950        .windows(2)
951        .all(|pair| pair[0].sender_id < pair[1].sender_id)
952        && boards.iter().all(|board| {
953            board
954                .reachable_endpoints
955                .iter()
956                .all(|ep| !is_router_control_endpoint(*ep))
957                && board
958                    .reachable_endpoints
959                    .windows(2)
960                    .all(|pair| pair[0] < pair[1])
961                && board
962                    .reachable_timesync_sources
963                    .windows(2)
964                    .all(|pair| pair[0] < pair[1])
965                && board
966                    .connections
967                    .iter()
968                    .all(|peer| peer != &board.sender_id)
969                && board.connections.windows(2).all(|pair| pair[0] < pair[1])
970        });
971    let mut owned = Vec::new();
972    let normalized = if canonical {
973        boards
974    } else {
975        owned.extend_from_slice(boards);
976        normalize_topology_boards(&mut owned);
977        &owned
978    };
979    let board_count = normalized.len();
980    let mut length = 4usize;
981    let mut add = |bytes: usize| {
982        length = length.saturating_add(bytes);
983    };
984    for board in normalized {
985        add(16);
986        add(board.sender_id.len());
987        add(board.reachable_endpoints.len().saturating_mul(4));
988        for value in board
989            .reachable_timesync_sources
990            .iter()
991            .chain(&board.connections)
992        {
993            add(4);
994            add(value.len());
995        }
996    }
997    if let Some(removed) = removed {
998        add(8);
999        for value in removed {
1000            add(4);
1001            add(value.len());
1002        }
1003    }
1004    let mut payload = Vec::new();
1005    if canonical {
1006        // Saturation becomes a fallible capacity error, never wrapped growth.
1007        payload
1008            .try_reserve_exact(length)
1009            .map_err(|_| TelemetryError::Io("memory pressure"))?;
1010    } else {
1011        // Preserve the legacy fallback peak: consume normalized owned nodes
1012        // as the payload grows instead of retaining another full graph.
1013        owned.reverse();
1014    }
1015    if removed.is_some() {
1016        payload.extend_from_slice(&DISCOVERY_TOPOLOGY_DELTA_MARKER.to_le_bytes());
1017    }
1018    payload.extend_from_slice(&(board_count as u32).to_le_bytes());
1019    for original in boards.iter().take(board_count) {
1020        let owned_board = owned.pop();
1021        let board = owned_board.as_ref().unwrap_or(original);
1022        encode_string(&mut payload, &board.sender_id)?;
1023
1024        payload.extend_from_slice(&(board.reachable_endpoints.len() as u32).to_le_bytes());
1025        for ep in &board.reachable_endpoints {
1026            payload.extend_from_slice(&ep.as_u32().to_le_bytes());
1027        }
1028
1029        payload.extend_from_slice(&(board.reachable_timesync_sources.len() as u32).to_le_bytes());
1030        for source in &board.reachable_timesync_sources {
1031            encode_string(&mut payload, source)?;
1032        }
1033
1034        payload.extend_from_slice(&(board.connections.len() as u32).to_le_bytes());
1035        for peer in &board.connections {
1036            encode_string(&mut payload, peer)?;
1037        }
1038    }
1039
1040    if let Some(removed) = removed {
1041        payload.extend_from_slice(&(removed.len() as u32).to_le_bytes());
1042        for sender_id in removed {
1043            encode_string(&mut payload, sender_id)?;
1044        }
1045    }
1046    debug_assert_eq!(payload.len(), length);
1047    Ok(payload)
1048}
1049
1050/// Builds a discovery packet advertising the sender's current board/edge topology graph.
1051pub fn build_discovery_topology(
1052    sender: &str,
1053    timestamp_ms: u64,
1054    boards: &[TopologyBoardNode],
1055) -> TelemetryResult<Packet> {
1056    let payload = topology_payload(boards, None)?;
1057
1058    Packet::new(
1059        DataType::DiscoveryTopology,
1060        &[DataEndpoint::Discovery],
1061        sender,
1062        timestamp_ms,
1063        payload.into(),
1064    )
1065}
1066
1067/// A topology update decoded from the wire. Full snapshots replace an
1068/// announcer's graph; incremental updates only upsert/remove named nodes.
1069#[derive(Debug, Clone, PartialEq, Eq)]
1070pub struct DiscoveryTopologyUpdate {
1071    pub incremental: bool,
1072    pub boards: Vec<TopologyBoardNode>,
1073    pub removed: Vec<String>,
1074}
1075
1076/// Applies a decoded, normalized full graph or node delta without copying the
1077/// retained graph merely to detect changes. Replacement nodes own their buffers.
1078pub fn apply_topology_update(
1079    retained: &mut Vec<TopologyBoardNode>,
1080    update: DiscoveryTopologyUpdate,
1081) -> bool {
1082    if !update.incremental {
1083        if *retained == update.boards {
1084            return false;
1085        }
1086        *retained = update.boards;
1087        return true;
1088    }
1089    let old_len = retained.len();
1090    retained.retain(|board| {
1091        !update.removed.iter().any(|id| id == &board.sender_id)
1092            || update
1093                .boards
1094                .iter()
1095                .any(|replacement| replacement.sender_id == board.sender_id)
1096    });
1097    let mut changed = old_len != retained.len();
1098    for board in update.boards {
1099        if let Some(old) = retained
1100            .iter_mut()
1101            .find(|old| old.sender_id == board.sender_id)
1102        {
1103            if *old != board {
1104                *old = board;
1105                changed = true;
1106            }
1107        } else {
1108            retained.push(board);
1109            changed = true;
1110        }
1111    }
1112    if changed {
1113        normalize_topology_boards(retained);
1114    }
1115    changed
1116}
1117
1118/// Computes the minimal named-node delta from one normalized graph to another.
1119pub fn topology_delta(
1120    previous: &[TopologyBoardNode],
1121    current: &[TopologyBoardNode],
1122) -> (Vec<TopologyBoardNode>, Vec<String>) {
1123    let boards = current
1124        .iter()
1125        .filter(|node| previous.iter().find(|old| old.sender_id == node.sender_id) != Some(*node))
1126        .cloned()
1127        .collect();
1128    let removed = previous
1129        .iter()
1130        .filter(|old| !current.iter().any(|node| node.sender_id == old.sender_id))
1131        .map(|old| old.sender_id.clone())
1132        .collect();
1133    (boards, removed)
1134}
1135
1136/// Builds a compact topology delta containing only changed and removed nodes.
1137pub fn build_discovery_topology_delta(
1138    sender: &str,
1139    timestamp_ms: u64,
1140    boards: &[TopologyBoardNode],
1141    removed: &[String],
1142) -> TelemetryResult<Packet> {
1143    let payload = topology_payload(boards, Some(removed))?;
1144    Packet::new(
1145        DataType::DiscoveryTopology,
1146        &[DataEndpoint::Discovery],
1147        sender,
1148        timestamp_ms,
1149        payload.into(),
1150    )
1151}
1152
1153fn decode_string(
1154    payload: &[u8],
1155    cursor: &mut usize,
1156    label: &'static str,
1157) -> TelemetryResult<String> {
1158    if payload.len().saturating_sub(*cursor) < 4 {
1159        return Err(TelemetryError::Unpack(label));
1160    }
1161    let len = u32::from_le_bytes(
1162        payload[*cursor..*cursor + 4]
1163            .try_into()
1164            .expect("4-byte len"),
1165    ) as usize;
1166    *cursor += 4;
1167    if payload.len().saturating_sub(*cursor) < len {
1168        return Err(TelemetryError::Unpack(label));
1169    }
1170    let raw = &payload[*cursor..*cursor + len];
1171    *cursor += len;
1172    core::str::from_utf8(raw)
1173        .map(|s| s.to_string())
1174        .map_err(|_| TelemetryError::Unpack(label))
1175}
1176
1177/// Decodes a discovery topology packet into board-node records.
1178pub fn decode_discovery_topology(pkt: &Packet) -> TelemetryResult<Vec<TopologyBoardNode>> {
1179    if pkt.data_type() != DataType::DiscoveryTopology {
1180        return Err(TelemetryError::InvalidType);
1181    }
1182    Ok(decode_discovery_topology_update(pkt)?.boards)
1183}
1184
1185/// Decodes either a legacy/full topology snapshot or an incremental delta.
1186pub fn decode_discovery_topology_update(pkt: &Packet) -> TelemetryResult<DiscoveryTopologyUpdate> {
1187    if pkt.data_type() != DataType::DiscoveryTopology {
1188        return Err(TelemetryError::InvalidType);
1189    }
1190    let payload = pkt.payload();
1191    if payload.len() < 4 {
1192        return Err(TelemetryError::Unpack("discovery topology board count"));
1193    }
1194    let first = u32::from_le_bytes(payload[..4].try_into().expect("4-byte count"));
1195    if first != DISCOVERY_TOPOLOGY_DELTA_MARKER {
1196        return Ok(DiscoveryTopologyUpdate {
1197            incremental: false,
1198            boards: decode_discovery_topology_payload(payload)?,
1199            removed: Vec::new(),
1200        });
1201    }
1202    if payload.len() < 8 {
1203        return Err(TelemetryError::Unpack("discovery topology delta count"));
1204    }
1205    let count = u32::from_le_bytes(payload[4..8].try_into().expect("4-byte count")) as usize;
1206    let mut cursor = 8usize;
1207    let mut boards = decode_topology_boards(payload, &mut cursor, count)?;
1208    if payload.len().saturating_sub(cursor) < 4 {
1209        return Err(TelemetryError::Unpack("discovery topology removal count"));
1210    }
1211    let removed_count = u32::from_le_bytes(
1212        payload[cursor..cursor + 4]
1213            .try_into()
1214            .expect("4-byte count"),
1215    ) as usize;
1216    cursor += 4;
1217    let mut removed = bounded_wire_vec(
1218        payload,
1219        cursor,
1220        removed_count,
1221        4,
1222        "discovery topology removal count",
1223    )?;
1224    for _ in 0..removed_count {
1225        removed.push(decode_string(
1226            payload,
1227            &mut cursor,
1228            "discovery topology removed sender id",
1229        )?);
1230    }
1231    if cursor != payload.len() {
1232        return Err(TelemetryError::Unpack("discovery topology trailing bytes"));
1233    }
1234    normalize_topology_boards(&mut boards);
1235    removed.sort_unstable();
1236    removed.dedup();
1237    Ok(DiscoveryTopologyUpdate {
1238        incremental: true,
1239        boards,
1240        removed,
1241    })
1242}
1243
1244/// Decodes a discovery topology payload into normalized board-node records.
1245pub fn decode_discovery_topology_payload(
1246    payload: &[u8],
1247) -> TelemetryResult<Vec<TopologyBoardNode>> {
1248    if payload.len() < 4 {
1249        return Err(TelemetryError::Unpack("discovery topology board count"));
1250    }
1251
1252    let count = u32::from_le_bytes(payload[..4].try_into().expect("4-byte count")) as usize;
1253    let mut cursor = 4usize;
1254    let mut boards = decode_topology_boards(payload, &mut cursor, count)?;
1255
1256    if cursor != payload.len() {
1257        return Err(TelemetryError::Unpack("discovery topology trailing bytes"));
1258    }
1259
1260    normalize_topology_boards(&mut boards);
1261    Ok(boards)
1262}
1263
1264fn decode_topology_boards(
1265    payload: &[u8],
1266    cursor: &mut usize,
1267    count: usize,
1268) -> TelemetryResult<Vec<TopologyBoardNode>> {
1269    /* Each board has at minimum a string length and three collection counts. */
1270    let mut boards = bounded_wire_vec(
1271        payload,
1272        *cursor,
1273        count,
1274        16,
1275        "discovery topology board count",
1276    )?;
1277
1278    for _ in 0..count {
1279        let sender_id = decode_string(payload, cursor, "discovery topology sender id")?;
1280
1281        if payload.len().saturating_sub(*cursor) < 4 {
1282            return Err(TelemetryError::Unpack("discovery topology endpoint count"));
1283        }
1284        let endpoint_count = u32::from_le_bytes(
1285            payload[*cursor..*cursor + 4]
1286                .try_into()
1287                .expect("4-byte count"),
1288        ) as usize;
1289        *cursor += 4;
1290        let mut reachable_endpoints = bounded_wire_vec(
1291            payload,
1292            *cursor,
1293            endpoint_count,
1294            4,
1295            "discovery topology endpoint count",
1296        )?;
1297        for _ in 0..endpoint_count {
1298            if payload.len().saturating_sub(*cursor) < 4 {
1299                return Err(TelemetryError::Unpack("discovery topology endpoint"));
1300            }
1301            let raw =
1302                u32::from_le_bytes(payload[*cursor..*cursor + 4].try_into().expect("4-byte ep"));
1303            *cursor += 4;
1304            let ep = DataEndpoint(raw);
1305            if !is_discovery_endpoint(ep) {
1306                reachable_endpoints.push(ep);
1307            }
1308        }
1309
1310        if payload.len().saturating_sub(*cursor) < 4 {
1311            return Err(TelemetryError::Unpack(
1312                "discovery topology timesync source count",
1313            ));
1314        }
1315        let source_count = u32::from_le_bytes(
1316            payload[*cursor..*cursor + 4]
1317                .try_into()
1318                .expect("4-byte count"),
1319        ) as usize;
1320        *cursor += 4;
1321        let mut reachable_timesync_sources = bounded_wire_vec(
1322            payload,
1323            *cursor,
1324            source_count,
1325            4,
1326            "discovery topology timesync source count",
1327        )?;
1328        for _ in 0..source_count {
1329            let source = decode_string(payload, cursor, "discovery topology timesync source")?;
1330            if !source.is_empty() {
1331                reachable_timesync_sources.push(source);
1332            }
1333        }
1334
1335        if payload.len().saturating_sub(*cursor) < 4 {
1336            return Err(TelemetryError::Unpack(
1337                "discovery topology connection count",
1338            ));
1339        }
1340        let connection_count = u32::from_le_bytes(
1341            payload[*cursor..*cursor + 4]
1342                .try_into()
1343                .expect("4-byte count"),
1344        ) as usize;
1345        *cursor += 4;
1346        let mut connections = bounded_wire_vec(
1347            payload,
1348            *cursor,
1349            connection_count,
1350            4,
1351            "discovery topology connection count",
1352        )?;
1353        for _ in 0..connection_count {
1354            let peer = decode_string(payload, cursor, "discovery topology connection")?;
1355            if !peer.is_empty() {
1356                connections.push(peer);
1357            }
1358        }
1359
1360        boards.push(TopologyBoardNode {
1361            sender_id,
1362            reachable_endpoints,
1363            reachable_timesync_sources,
1364            connections,
1365        });
1366    }
1367
1368    Ok(boards)
1369}
1370
1371fn encode_string(payload: &mut Vec<u8>, value: &str) -> TelemetryResult<()> {
1372    let len = u32::try_from(value.len())
1373        .map_err(|_| TelemetryError::Pack("discovery schema string too long"))?;
1374    payload.extend_from_slice(&len.to_le_bytes());
1375    payload.extend_from_slice(value.as_bytes());
1376    Ok(())
1377}
1378
1379fn read_u8(payload: &[u8], cursor: &mut usize, label: &'static str) -> TelemetryResult<u8> {
1380    if payload.len().saturating_sub(*cursor) < 1 {
1381        return Err(TelemetryError::Unpack(label));
1382    }
1383    let out = payload[*cursor];
1384    *cursor += 1;
1385    Ok(out)
1386}
1387
1388fn read_u32(payload: &[u8], cursor: &mut usize, label: &'static str) -> TelemetryResult<u32> {
1389    if payload.len().saturating_sub(*cursor) < 4 {
1390        return Err(TelemetryError::Unpack(label));
1391    }
1392    let out = u32::from_le_bytes(
1393        payload[*cursor..*cursor + 4]
1394            .try_into()
1395            .expect("4-byte u32"),
1396    );
1397    *cursor += 4;
1398    Ok(out)
1399}
1400
1401#[cfg(any(not(feature = "std"), test))]
1402fn skip_string(payload: &[u8], cursor: &mut usize, label: &'static str) -> TelemetryResult<()> {
1403    let len = read_u32(payload, cursor, label)? as usize;
1404    if payload.len().saturating_sub(*cursor) < len {
1405        return Err(TelemetryError::Unpack(label));
1406    }
1407    *cursor += len;
1408    Ok(())
1409}
1410
1411#[cfg(any(not(feature = "std"), test))]
1412fn borrowed_schema_string<'a>(
1413    payload: &'a [u8],
1414    cursor: &mut usize,
1415    label: &'static str,
1416) -> TelemetryResult<&'a str> {
1417    let len = read_u32(payload, cursor, label)? as usize;
1418    let end = cursor
1419        .checked_add(len)
1420        .ok_or(TelemetryError::Unpack(label))?;
1421    let bytes = payload
1422        .get(*cursor..end)
1423        .ok_or(TelemetryError::Unpack(label))?;
1424    *cursor = end;
1425    core::str::from_utf8(bytes).map_err(|_| TelemetryError::Unpack(label))
1426}
1427
1428/// Checks a version-3 schema payload without allocating any owned strings or
1429/// vectors. Embedded routers use this before the full decoder so repeated
1430/// snapshots equivalent to compiled/previously learned definitions do not create a
1431/// large transient heap burst merely to discard every decoded definition.
1432#[cfg(any(not(feature = "std"), test))]
1433pub(crate) fn discovery_schema_payload_is_fully_known(payload: &[u8]) -> TelemetryResult<bool> {
1434    let mut cursor = 0usize;
1435    if read_u32(payload, &mut cursor, "discovery schema version")? != 3 {
1436        return Ok(false);
1437    }
1438
1439    let endpoint_count =
1440        read_u32(payload, &mut cursor, "discovery schema endpoint count")? as usize;
1441    if endpoint_count > payload.len().saturating_sub(cursor) / 13 {
1442        return Err(TelemetryError::Unpack("discovery schema endpoint count"));
1443    }
1444    let mut all_known = true;
1445    for _ in 0..endpoint_count {
1446        let id = DataEndpoint(read_u32(
1447            payload,
1448            &mut cursor,
1449            "discovery schema endpoint id",
1450        )?);
1451        let known = crate::config::endpoint_exists(id);
1452        let meta = crate::config::get_endpoint_meta(id);
1453        let link_local_only =
1454            read_u8(payload, &mut cursor, "discovery schema endpoint flags")? != 0;
1455        let name = borrowed_schema_string(payload, &mut cursor, "discovery schema endpoint name")?;
1456        all_known &= known
1457            && AsRef::<str>::as_ref(&meta.name) == name
1458            && (!link_local_only || meta.link_local_only);
1459        skip_string(
1460            payload,
1461            &mut cursor,
1462            "discovery schema endpoint description",
1463        )?;
1464    }
1465
1466    let type_count = read_u32(payload, &mut cursor, "discovery schema type count")? as usize;
1467    if type_count > payload.len().saturating_sub(cursor) / 19 {
1468        return Err(TelemetryError::Unpack("discovery schema type count"));
1469    }
1470    for _ in 0..type_count {
1471        let id = DataType(read_u32(payload, &mut cursor, "discovery schema type id")?);
1472        let meta = crate::config::try_get_message_meta(id);
1473        let name = borrowed_schema_string(payload, &mut cursor, "discovery schema type name")?;
1474        skip_string(payload, &mut cursor, "discovery schema type description")?;
1475        let element_kind = read_u8(payload, &mut cursor, "discovery schema element kind")?;
1476        if element_kind > 1 {
1477            return Err(TelemetryError::Unpack("discovery schema element kind"));
1478        }
1479        let count = read_u32(payload, &mut cursor, "discovery schema element count")? as usize;
1480        let data_type = message_data_type_from_code(read_u8(
1481            payload,
1482            &mut cursor,
1483            "discovery schema data type",
1484        )?)
1485        .ok_or(TelemetryError::Unpack("discovery schema data type"))?;
1486        let class =
1487            message_class_from_code(read_u8(payload, &mut cursor, "discovery schema class")?)
1488                .ok_or(TelemetryError::Unpack("discovery schema class"))?;
1489        let reliable =
1490            reliable_from_code(read_u8(payload, &mut cursor, "discovery schema reliable")?)
1491                .ok_or(TelemetryError::Unpack("discovery schema reliable"))?;
1492        let priority = read_u8(payload, &mut cursor, "discovery schema priority")?;
1493        let encryption = e2e_encryption_policy_from_code(read_u8(
1494            payload,
1495            &mut cursor,
1496            "discovery schema e2e cryptography",
1497        )?)
1498        .ok_or(TelemetryError::Unpack("discovery schema e2e cryptography"))?;
1499        let endpoint_count =
1500            read_u32(payload, &mut cursor, "discovery schema type endpoint count")? as usize;
1501        if endpoint_count > payload.len().saturating_sub(cursor) / 4 {
1502            return Err(TelemetryError::Unpack(
1503                "discovery schema type endpoint count",
1504            ));
1505        }
1506        let element = if element_kind == 0 {
1507            MessageElement::Static(count, data_type, class)
1508        } else {
1509            MessageElement::Dynamic(data_type, class)
1510        };
1511        all_known &= meta.as_ref().is_some_and(|meta| {
1512            AsRef::<str>::as_ref(&meta.name) == name
1513                && meta.element == element
1514                && meta.reliable == reliable
1515                && meta.priority >= priority
1516                && meta.e2e_encryption == encryption
1517        });
1518        for _ in 0..endpoint_count {
1519            let ep = DataEndpoint(read_u32(
1520                payload,
1521                &mut cursor,
1522                "discovery schema type endpoint",
1523            )?);
1524            all_known &= meta
1525                .as_ref()
1526                .is_some_and(|meta| meta.endpoints.contains(&ep));
1527        }
1528    }
1529    if cursor != payload.len() {
1530        return Err(TelemetryError::Unpack("discovery schema trailing bytes"));
1531    }
1532    Ok(all_known)
1533}
1534
1535/// Builds a discovery packet containing the complete runtime schema snapshot.
1536pub fn build_discovery_schema(sender: &str, timestamp_ms: u64) -> TelemetryResult<Packet> {
1537    #[cfg(feature = "std")]
1538    return build_discovery_schema_from_owned_snapshot(sender, timestamp_ms, export_schema());
1539    #[cfg(not(feature = "std"))]
1540    return Packet::new(
1541        DataType::DiscoverySchema,
1542        &[DataEndpoint::Discovery],
1543        sender,
1544        timestamp_ms,
1545        crate::config::encode_embedded_schema_payload()?,
1546    );
1547}
1548
1549/// Elects the authoritative discovery/schema master for the current topology view.
1550///
1551/// Tie-breaks are deterministic:
1552/// 1. Fewest unreachable boards.
1553/// 2. Lowest maximum hop distance to any reachable board.
1554/// 3. Lowest total hop distance across all reachable boards.
1555/// 4. Lexicographically smallest sender ID.
1556pub fn elect_discovery_master(local_sender: &str, boards: &[TopologyBoardNode]) -> String {
1557    let mut nodes: BTreeMap<String, Vec<String>> = BTreeMap::new();
1558    for board in boards {
1559        nodes.entry(board.sender_id.clone()).or_default();
1560        for peer in board.connections.iter() {
1561            nodes
1562                .entry(peer.clone())
1563                .or_default()
1564                .push(board.sender_id.clone());
1565            nodes
1566                .entry(board.sender_id.clone())
1567                .or_default()
1568                .push(peer.clone());
1569        }
1570    }
1571    nodes.entry(local_sender.to_string()).or_default();
1572    for peers in nodes.values_mut() {
1573        peers.sort_unstable();
1574        peers.dedup();
1575    }
1576
1577    let mut best_sender = local_sender.to_string();
1578    let mut best_unreachable = usize::MAX;
1579    let mut best_max_hops = usize::MAX;
1580    let mut best_total_hops = usize::MAX;
1581
1582    for sender in nodes.keys() {
1583        let mut frontier = vec![sender.clone()];
1584        let mut seen: BTreeMap<String, usize> = BTreeMap::new();
1585        seen.insert(sender.clone(), 0);
1586        let mut idx = 0;
1587        while idx < frontier.len() {
1588            let cur = frontier[idx].clone();
1589            idx += 1;
1590            let cur_dist = seen[&cur];
1591            if let Some(peers) = nodes.get(&cur) {
1592                for peer in peers {
1593                    if !seen.contains_key(peer) {
1594                        seen.insert(peer.clone(), cur_dist + 1);
1595                        frontier.push(peer.clone());
1596                    }
1597                }
1598            }
1599        }
1600
1601        let unreachable = nodes.len().saturating_sub(seen.len());
1602        let max_hops = seen.values().copied().max().unwrap_or(0);
1603        let total_hops = seen.values().copied().sum();
1604        let better = unreachable < best_unreachable
1605            || (unreachable == best_unreachable && max_hops < best_max_hops)
1606            || (unreachable == best_unreachable
1607                && max_hops == best_max_hops
1608                && total_hops < best_total_hops)
1609            || (unreachable == best_unreachable
1610                && max_hops == best_max_hops
1611                && total_hops == best_total_hops
1612                && sender < &best_sender);
1613        if better {
1614            best_sender = sender.clone();
1615            best_unreachable = unreachable;
1616            best_max_hops = max_hops;
1617            best_total_hops = total_hops;
1618        }
1619    }
1620
1621    best_sender
1622}
1623
1624/// Builds a discovery schema packet from an explicit snapshot.
1625pub fn build_discovery_schema_from_snapshot(
1626    sender: &str,
1627    timestamp_ms: u64,
1628    schema: RuntimeSchemaSnapshot,
1629) -> TelemetryResult<Packet> {
1630    let mut payload = Vec::new();
1631    payload.extend_from_slice(&3u32.to_le_bytes());
1632
1633    payload.extend_from_slice(&(schema.endpoints.len() as u32).to_le_bytes());
1634    for ep in schema.endpoints {
1635        payload.extend_from_slice(&ep.id.as_u32().to_le_bytes());
1636        payload.push(ep.link_local_only as u8);
1637        encode_string(&mut payload, ep.name)?;
1638        #[cfg(feature = "std")]
1639        encode_string(&mut payload, ep.description)?;
1640        #[cfg(not(feature = "std"))]
1641        encode_string(&mut payload, "")?;
1642    }
1643
1644    payload.extend_from_slice(&(schema.types.len() as u32).to_le_bytes());
1645    for ty in schema.types {
1646        payload.extend_from_slice(&ty.id.as_u32().to_le_bytes());
1647        encode_string(&mut payload, ty.name)?;
1648        #[cfg(feature = "std")]
1649        encode_string(&mut payload, ty.description)?;
1650        #[cfg(not(feature = "std"))]
1651        encode_string(&mut payload, "")?;
1652        match ty.element {
1653            MessageElement::Static(count, data_type, class) => {
1654                payload.push(0);
1655                payload.extend_from_slice(&(count as u32).to_le_bytes());
1656                payload.push(message_data_type_code(data_type));
1657                payload.push(message_class_code(class));
1658            }
1659            MessageElement::Dynamic(data_type, class) => {
1660                payload.push(1);
1661                payload.extend_from_slice(&0u32.to_le_bytes());
1662                payload.push(message_data_type_code(data_type));
1663                payload.push(message_class_code(class));
1664            }
1665        }
1666        payload.push(reliable_code(ty.reliable));
1667        payload.push(ty.priority);
1668        payload.push(e2e_encryption_policy_code(ty.e2e_encryption));
1669        payload.extend_from_slice(&(ty.endpoints.len() as u32).to_le_bytes());
1670        for ep in ty.endpoints {
1671            payload.extend_from_slice(&ep.as_u32().to_le_bytes());
1672        }
1673    }
1674
1675    Packet::new(
1676        DataType::DiscoverySchema,
1677        &[DataEndpoint::Discovery],
1678        sender,
1679        timestamp_ms,
1680        payload.into(),
1681    )
1682}
1683
1684/// Builds a discovery schema packet from an owned snapshot.
1685pub fn build_discovery_schema_from_owned_snapshot(
1686    sender: &str,
1687    timestamp_ms: u64,
1688    schema: OwnedRuntimeSchemaSnapshot,
1689) -> TelemetryResult<Packet> {
1690    let mut payload = Vec::new();
1691    payload.extend_from_slice(&3u32.to_le_bytes());
1692
1693    payload.extend_from_slice(&(schema.endpoints.len() as u32).to_le_bytes());
1694    for ep in schema.endpoints {
1695        payload.extend_from_slice(&ep.id.as_u32().to_le_bytes());
1696        payload.push(ep.link_local_only as u8);
1697        encode_string(&mut payload, &ep.name)?;
1698        // Descriptions are documentation, not routing metadata. Every node
1699        // keeps its local copy, while omitting them here prevents schema sync
1700        // from consuming constrained radio/CAN queue capacity.
1701        encode_string(&mut payload, "")?;
1702    }
1703
1704    payload.extend_from_slice(&(schema.types.len() as u32).to_le_bytes());
1705    for ty in schema.types {
1706        payload.extend_from_slice(&ty.id.as_u32().to_le_bytes());
1707        encode_string(&mut payload, &ty.name)?;
1708        encode_string(&mut payload, "")?;
1709        match ty.element {
1710            MessageElement::Static(count, data_type, class) => {
1711                payload.push(0);
1712                payload.extend_from_slice(&(count as u32).to_le_bytes());
1713                payload.push(message_data_type_code(data_type));
1714                payload.push(message_class_code(class));
1715            }
1716            MessageElement::Dynamic(data_type, class) => {
1717                payload.push(1);
1718                payload.extend_from_slice(&0u32.to_le_bytes());
1719                payload.push(message_data_type_code(data_type));
1720                payload.push(message_class_code(class));
1721            }
1722        }
1723        payload.push(reliable_code(ty.reliable));
1724        payload.push(ty.priority);
1725        payload.push(e2e_encryption_policy_code(ty.e2e_encryption));
1726        payload.extend_from_slice(&(ty.endpoints.len() as u32).to_le_bytes());
1727        for ep in ty.endpoints {
1728            payload.extend_from_slice(&ep.as_u32().to_le_bytes());
1729        }
1730    }
1731
1732    Packet::new(
1733        DataType::DiscoverySchema,
1734        &[DataEndpoint::Discovery],
1735        sender,
1736        timestamp_ms,
1737        payload.into(),
1738    )
1739}
1740
1741/// Decodes a discovery schema packet.
1742pub fn decode_discovery_schema(pkt: &Packet) -> TelemetryResult<OwnedRuntimeSchemaSnapshot> {
1743    if pkt.data_type() != DataType::DiscoverySchema {
1744        return Err(TelemetryError::InvalidType);
1745    }
1746    decode_discovery_schema_payload(pkt.payload())
1747}
1748
1749/// Decodes a discovery schema payload into runtime definitions.
1750pub fn decode_discovery_schema_payload(
1751    payload: &[u8],
1752) -> TelemetryResult<OwnedRuntimeSchemaSnapshot> {
1753    let mut cursor = 0usize;
1754    let version = read_u32(payload, &mut cursor, "discovery schema version")?;
1755    #[cfg(feature = "std")]
1756    if version != 1 && version != 2 && version != 3 {
1757        return Err(TelemetryError::Unpack("discovery schema version"));
1758    }
1759    #[cfg(not(feature = "std"))]
1760    if version != 3 {
1761        return Err(TelemetryError::Unpack("discovery schema version"));
1762    }
1763
1764    let endpoint_count =
1765        read_u32(payload, &mut cursor, "discovery schema endpoint count")? as usize;
1766    let mut endpoints = bounded_wire_vec(
1767        payload,
1768        cursor,
1769        endpoint_count,
1770        13,
1771        "discovery schema endpoint count",
1772    )?;
1773    for _ in 0..endpoint_count {
1774        let id = DataEndpoint(read_u32(
1775            payload,
1776            &mut cursor,
1777            "discovery schema endpoint id",
1778        )?);
1779        let link_local_only =
1780            read_u8(payload, &mut cursor, "discovery schema endpoint flags")? != 0;
1781        let name = decode_string(payload, &mut cursor, "discovery schema endpoint name")?;
1782        #[cfg(feature = "std")]
1783        let description = if version >= 2 {
1784            decode_string(
1785                payload,
1786                &mut cursor,
1787                "discovery schema endpoint description",
1788            )?
1789        } else {
1790            String::new()
1791        };
1792        #[cfg(not(feature = "std"))]
1793        let description = {
1794            skip_string(
1795                payload,
1796                &mut cursor,
1797                "discovery schema endpoint description",
1798            )?;
1799            String::new()
1800        };
1801        endpoints.push(OwnedEndpointDefinition {
1802            id,
1803            name,
1804            description,
1805            link_local_only,
1806        });
1807    }
1808
1809    let type_count = read_u32(payload, &mut cursor, "discovery schema type count")? as usize;
1810    let mut types = bounded_wire_vec(
1811        payload,
1812        cursor,
1813        type_count,
1814        19,
1815        "discovery schema type count",
1816    )?;
1817    for _ in 0..type_count {
1818        let id = DataType(read_u32(payload, &mut cursor, "discovery schema type id")?);
1819        let name = decode_string(payload, &mut cursor, "discovery schema type name")?;
1820        #[cfg(feature = "std")]
1821        let description = if version >= 2 {
1822            decode_string(payload, &mut cursor, "discovery schema type description")?
1823        } else {
1824            String::new()
1825        };
1826        #[cfg(not(feature = "std"))]
1827        let description = {
1828            skip_string(payload, &mut cursor, "discovery schema type description")?;
1829            String::new()
1830        };
1831        let element_kind = read_u8(payload, &mut cursor, "discovery schema element kind")?;
1832        let count = read_u32(payload, &mut cursor, "discovery schema element count")? as usize;
1833        let data_type = message_data_type_from_code(read_u8(
1834            payload,
1835            &mut cursor,
1836            "discovery schema data type",
1837        )?)
1838        .ok_or(TelemetryError::Unpack("discovery schema data type"))?;
1839        let class =
1840            message_class_from_code(read_u8(payload, &mut cursor, "discovery schema class")?)
1841                .ok_or(TelemetryError::Unpack("discovery schema class"))?;
1842        let element = match element_kind {
1843            0 => MessageElement::Static(count, data_type, class),
1844            1 => MessageElement::Dynamic(data_type, class),
1845            _ => return Err(TelemetryError::Unpack("discovery schema element kind")),
1846        };
1847        let reliable =
1848            reliable_from_code(read_u8(payload, &mut cursor, "discovery schema reliable")?)
1849                .ok_or(TelemetryError::Unpack("discovery schema reliable"))?;
1850        let priority = read_u8(payload, &mut cursor, "discovery schema priority")?;
1851        #[cfg(feature = "std")]
1852        let e2e_encryption = if version >= 3 {
1853            e2e_encryption_policy_from_code(read_u8(
1854                payload,
1855                &mut cursor,
1856                "discovery schema e2e cryptography",
1857            )?)
1858            .ok_or(TelemetryError::Unpack("discovery schema e2e cryptography"))?
1859        } else {
1860            E2eEncryptionPolicy::PreferOff
1861        };
1862        #[cfg(not(feature = "std"))]
1863        let e2e_encryption = e2e_encryption_policy_from_code(read_u8(
1864            payload,
1865            &mut cursor,
1866            "discovery schema e2e cryptography",
1867        )?)
1868        .ok_or(TelemetryError::Unpack("discovery schema e2e cryptography"))?;
1869        let endpoint_count =
1870            read_u32(payload, &mut cursor, "discovery schema type endpoint count")? as usize;
1871        let mut type_endpoints = bounded_wire_vec(
1872            payload,
1873            cursor,
1874            endpoint_count,
1875            4,
1876            "discovery schema type endpoint count",
1877        )?;
1878        for _ in 0..endpoint_count {
1879            type_endpoints.push(DataEndpoint(read_u32(
1880                payload,
1881                &mut cursor,
1882                "discovery schema type endpoint",
1883            )?));
1884        }
1885        types.push(OwnedDataTypeDefinition {
1886            id,
1887            name,
1888            description,
1889            element,
1890            endpoints: type_endpoints,
1891            reliable,
1892            priority,
1893            e2e_encryption,
1894        });
1895    }
1896
1897    if cursor != payload.len() {
1898        return Err(TelemetryError::Unpack("discovery schema trailing bytes"));
1899    }
1900    Ok(OwnedRuntimeSchemaSnapshot { endpoints, types })
1901}
1902
1903/// An unchanged catalog can reuse a queued/in-progress transfer. Timestamp and
1904/// nonce intentionally differ on repeated announcements; routing contracts do
1905/// not. Changed metadata, sender or destination must still replace the snapshot.
1906pub(crate) fn same_schema_snapshot(existing: &Packet, incoming: &Packet) -> bool {
1907    existing.data_type() == DataType::DiscoverySchema
1908        && incoming.data_type() == DataType::DiscoverySchema
1909        && existing.sender() == incoming.sender()
1910        && existing.endpoints() == incoming.endpoints()
1911        && existing.wire_target_senders() == incoming.wire_target_senders()
1912        && existing.payload() == incoming.payload()
1913}
1914
1915#[cfg(test)]
1916mod bounded_decode_tests {
1917    use super::{
1918        ADDRESS_MODE_STATIC, ADDRESS_STATE_APPROVED, AddressAdvertisement, LinkCapabilities,
1919        build_discovery_address, build_managed_variable_request, decode_discovery_address,
1920        decode_discovery_schema_payload, decode_discovery_timesync_sources_payload,
1921        decode_discovery_topology_payload, decode_managed_variable_request,
1922        discovery_schema_payload_is_fully_known,
1923    };
1924    use crate::{DataEndpoint, DataType};
1925
1926    #[test]
1927    fn equivalent_schema_definitions_are_detected_without_owned_decode() {
1928        fn one_endpoint(id: DataEndpoint) -> Vec<u8> {
1929            let mut payload = Vec::new();
1930            payload.extend_from_slice(&3u32.to_le_bytes());
1931            payload.extend_from_slice(&1u32.to_le_bytes());
1932            payload.extend_from_slice(&id.0.to_le_bytes());
1933            payload.push(0);
1934            let meta = crate::config::get_endpoint_meta(id);
1935            payload.extend_from_slice(&(meta.name.len() as u32).to_le_bytes());
1936            payload.extend_from_slice(meta.name.as_bytes());
1937            payload.extend_from_slice(&0u32.to_le_bytes());
1938            payload.extend_from_slice(&0u32.to_le_bytes());
1939            payload
1940        }
1941
1942        assert!(
1943            discovery_schema_payload_is_fully_known(&one_endpoint(DataEndpoint::Discovery,))
1944                .unwrap()
1945        );
1946        assert!(
1947            !discovery_schema_payload_is_fully_known(&one_endpoint(DataEndpoint(u32::MAX - 7),))
1948                .unwrap()
1949        );
1950    }
1951
1952    #[test]
1953    fn reused_ids_with_changed_schema_are_not_treated_as_known() {
1954        let schema = crate::config::export_schema();
1955        let original =
1956            super::build_discovery_schema_from_owned_snapshot("test", 1, schema.clone()).unwrap();
1957        assert!(discovery_schema_payload_is_fully_known(original.payload()).unwrap());
1958        for change in 0..4 {
1959            let mut updated = schema.clone();
1960            match change {
1961                0 => updated.endpoints[0].name.push_str("_CHANGED"),
1962                1 => updated.endpoints[0].link_local_only = !updated.endpoints[0].link_local_only,
1963                2 => updated.types[0].name.push_str("_CHANGED"),
1964                _ => updated.types[0].endpoints.push(DataEndpoint(u32::MAX - 7)),
1965            }
1966            let packet =
1967                super::build_discovery_schema_from_owned_snapshot("test", 2, updated).unwrap();
1968            assert!(
1969                !discovery_schema_payload_is_fully_known(packet.payload()).unwrap(),
1970                "change {change} was silently ignored"
1971            );
1972        }
1973    }
1974
1975    #[test]
1976    fn unknown_managed_variable_ids_survive_discovery_before_schema_merge() {
1977        let unknown = DataType(4090);
1978        let unknown_endpoint = DataEndpoint(4090);
1979        let advertisement = AddressAdvertisement {
1980            hostname: "flight".into(),
1981            address: 42,
1982            requested_address: 42,
1983            mode: ADDRESS_MODE_STATIC,
1984            state: ADDRESS_STATE_APPROVED,
1985            birth_ms: 10,
1986            owner_hash: 20,
1987            reachable_endpoints: vec![unknown_endpoint],
1988            reachable_network_variables: vec![unknown],
1989            reachable_timesync_sources: Vec::new(),
1990            link_capabilities: LinkCapabilities {
1991                version: 1,
1992                flags: 0,
1993                profile: 0,
1994                max_frame_bytes: 128,
1995                compact_header_target_bytes: 40,
1996                max_side_transport_templates: 4,
1997            },
1998        };
1999        let packet = build_discovery_address("FC", 1, &advertisement).unwrap();
2000        let decoded = decode_discovery_address(&packet).unwrap();
2001        assert_eq!(decoded.reachable_endpoints, vec![unknown_endpoint]);
2002        assert_eq!(decoded.reachable_network_variables, vec![unknown]);
2003
2004        let request = build_managed_variable_request("GS", 2, unknown).unwrap();
2005        assert_eq!(decode_managed_variable_request(&request).unwrap(), unknown);
2006    }
2007
2008    #[test]
2009    fn corrupt_timesync_count_is_rejected_without_reserving() {
2010        assert!(decode_discovery_timesync_sources_payload(&u32::MAX.to_le_bytes()).is_err());
2011    }
2012
2013    #[test]
2014    fn corrupt_topology_count_is_rejected_without_reserving() {
2015        assert!(decode_discovery_topology_payload(&u32::MAX.to_le_bytes()).is_err());
2016    }
2017
2018    #[test]
2019    fn corrupt_schema_counts_are_rejected_without_reserving() {
2020        let mut endpoints = 3u32.to_le_bytes().to_vec();
2021        endpoints.extend_from_slice(&u32::MAX.to_le_bytes());
2022        assert!(decode_discovery_schema_payload(&endpoints).is_err());
2023
2024        let mut types = 3u32.to_le_bytes().to_vec();
2025        types.extend_from_slice(&0u32.to_le_bytes());
2026        types.extend_from_slice(&u32::MAX.to_le_bytes());
2027        assert!(decode_discovery_schema_payload(&types).is_err());
2028    }
2029}