Skip to main content

sedsnet/
discovery.rs

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