Skip to main content

sedsnet/
discovery.rs

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