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