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