zerodds_dcps/runtime.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! DcpsRuntime — event loop + UDP sockets per DomainParticipant.
4//!
5//! # Structure
6//!
7//! - Binds 3 UDP sockets per participant:
8//! * SPDP multicast receiver (domain-based port).
9//! * SPDP unicast fallback (ephemeral, for bidirectional SPDP).
10//! * User unicast (ephemeral, where matched peers send to).
11//! - Spawns a single event-loop thread that periodically:
12//! * sends the SPDP beacon (every 5 s by default),
13//! * polls all sockets non-blocking,
14//! * moves SPDP datagrams into the DiscoveredParticipantsCache,
15//! * dispatches SEDP datagrams (pub/sub announces),
16//! * delivers user data to the correct DataReader slots,
17//! * runs the WLP/liveliness tick,
18//! * serves the TypeLookup service endpoints (XTypes 1.3 §7.6.3.3.4).
19//! - Thread lifecycle via `Arc<AtomicBool> stop_flag` + `JoinHandle` in
20//! `Drop`.
21//!
22//! With the `security` feature active, all outbound/inbound bytes pass
23//! through the `SharedSecurityGate` (DDS-Security 1.2). Multi-interface
24//! binding (RuntimeConfig::interface_bindings) enables per-subnet routing
25//! for production topologies.
26
27extern crate alloc;
28use alloc::collections::BTreeMap;
29use alloc::string::String;
30use alloc::sync::Arc;
31use alloc::vec::Vec;
32use core::time::Duration;
33use std::net::{Ipv4Addr, SocketAddr};
34use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
35use std::sync::mpsc;
36use std::sync::{Condvar, Mutex, RwLock};
37use std::thread::{self, JoinHandle};
38use std::time::Instant;
39
40use zerodds_discovery::security::SecurityBuiltinStack;
41use zerodds_discovery::sedp::SedpStack;
42use zerodds_discovery::spdp::{
43 DiscoveredParticipant, DiscoveredParticipantsCache, SpdpBeacon, SpdpReader,
44};
45use zerodds_discovery::type_lookup::{
46 TypeLookupClient, TypeLookupEndpoints, TypeLookupReply, TypeLookupServer,
47};
48use zerodds_qos::Duration as QosDuration;
49use zerodds_rtps::EntityId;
50use zerodds_rtps::datagram::{ParsedSubmessage, decode_datagram};
51use zerodds_rtps::fragment_assembler::AssemblerCaps;
52use zerodds_rtps::history_cache::HistoryKind;
53use zerodds_rtps::message_builder::DEFAULT_MTU;
54use zerodds_rtps::participant_data::{ParticipantBuiltinTopicData, endpoint_flag};
55use zerodds_rtps::reliable_reader::{ReliableReader, ReliableReaderConfig};
56use zerodds_rtps::reliable_writer::{
57 DEFAULT_FRAGMENT_SIZE, DEFAULT_HEARTBEAT_PERIOD, LOOPBACK_FRAGMENT_SIZE, LOOPBACK_MTU,
58 ReliableWriter, ReliableWriterConfig,
59};
60use zerodds_rtps::wire_types::{
61 Guid, GuidPrefix, Locator, LocatorKind, ProtocolVersion, SPDP_DEFAULT_MULTICAST_ADDRESS,
62 VendorId, spdp_multicast_port,
63};
64use zerodds_transport::Transport;
65use zerodds_transport_udp::UdpTransport;
66
67#[cfg(feature = "security")]
68use zerodds_security_runtime::{EndpointProtection, IpRange, NetInterface, ProtectionLevel};
69
70use crate::error::{DdsError, Result};
71
72/// Default tick period of the event loop.
73///
74/// This is the worst-case quantization for sub-tick-driven tasks
75/// (SEDP heartbeats, reliable-writer resends, ACKNACK emit). Short enough
76/// for sub-ms round-trip latency (5 ms = 100 Hz tick rate), long enough
77/// to keep idle CPU cost small.
78///
79/// Phase-3 migration: this tick loop is replaced by a deadline heap +
80/// condvar worker (`scheduler.rs`) — then this value is only the
81/// idle-floor sleep (no quantization tax for events).
82pub const DEFAULT_TICK_PERIOD: Duration = Duration::from_millis(5);
83
84/// Default SPDP announce period (Spec §8.5.3.2 recommends 5 s).
85pub const DEFAULT_SPDP_PERIOD: Duration = Duration::from_secs(5);
86
87/// Default number of SPDP announces sent at the fast initial-burst cadence
88/// (C3 WiFi-robust discovery) before falling back to [`DEFAULT_SPDP_PERIOD`].
89/// Analogous to Fast DDS `initial_announcements`.
90pub const DEFAULT_INITIAL_ANNOUNCE_COUNT: u32 = 10;
91
92/// Default period between initial-announcement-burst SPDP sends.
93pub const DEFAULT_INITIAL_ANNOUNCE_PERIOD: Duration = Duration::from_millis(200);
94
95/// Deadline/lease compat check: the offered period must be <= requested.
96/// `0` is the sentinel for INFINITE — there any combination is compatible
97/// (offered INFINITE implies "I promise nothing faster than infinity",
98/// but a reader with INFINITE also requests nothing).
99fn deadline_compat(offered_nanos: u64, requested_nanos: u64) -> bool {
100 if offered_nanos == 0 || requested_nanos == 0 {
101 // INFINITE on one side → compatible.
102 return true;
103 }
104 offered_nanos <= requested_nanos
105}
106
107/// Partition matching: both sides have at least one common partition OR
108/// both are empty (default partition "").
109/// Enforces a per-instance KeepLast depth over a TransientLocal retained-sample
110/// buffer (DDS 1.4 §2.2.3.18). Keeps at most `depth` *Alive* entries per
111/// instance key (oldest evicted first), preserving order; terminal lifecycle
112/// markers (dispose/unregister) are never counted against the depth and are
113/// always retained so a late joiner observes the final NOT_ALIVE state.
114fn enforce_retained_depth(
115 retained: &mut alloc::collections::VecDeque<RetainedSample>,
116 depth: usize,
117) {
118 use alloc::collections::BTreeMap;
119 // Count alive samples per key.
120 let mut alive_per_key: BTreeMap<[u8; 16], usize> = BTreeMap::new();
121 for s in retained.iter() {
122 if s.lifecycle.is_none() {
123 *alive_per_key.entry(s.key_hash).or_insert(0) += 1;
124 }
125 }
126 // Determine how many to drop per key.
127 let mut to_drop: BTreeMap<[u8; 16], usize> = BTreeMap::new();
128 for (k, count) in &alive_per_key {
129 if *count > depth {
130 to_drop.insert(*k, count - depth);
131 }
132 }
133 if to_drop.is_empty() {
134 return;
135 }
136 retained.retain(|s| {
137 if s.lifecycle.is_some() {
138 return true;
139 }
140 if let Some(rem) = to_drop.get_mut(&s.key_hash) {
141 if *rem > 0 {
142 *rem -= 1;
143 return false; // evict this (oldest) alive sample for the key
144 }
145 }
146 true
147 });
148}
149
150fn partitions_overlap(offered: &[String], requested: &[String]) -> bool {
151 if offered.is_empty() && requested.is_empty() {
152 return true;
153 }
154 // An empty list is treated as ["" (default)].
155 let off_default = offered.is_empty();
156 let req_default = requested.is_empty();
157 if off_default && requested.iter().any(|s| s.is_empty()) {
158 return true;
159 }
160 if req_default && offered.iter().any(|s| s.is_empty()) {
161 return true;
162 }
163 // Both non-default: intersect.
164 offered.iter().any(|o| requested.iter().any(|r| r == o))
165}
166
167/// Materializes the locator address that we announce in the SPDP beacon
168/// from an UdpTransport bound to UNSPECIFIED.
169///
170/// Binding to `0.0.0.0` yields `local_addr() == 0.0.0.0:port`, which is
171/// not routable for peers. Via a UDP connect probe to a non-routable
172/// address we resolve the outbound interface address (no traffic —
173/// `connect()` on a UDP socket only sets the routing information). Falls
174/// back to `multicast_interface` (RuntimeConfig) if the probe fails, or
175/// to the unchanged locator as a last resort.
176#[cfg(feature = "std")]
177fn announce_locator(uc: &(dyn Transport + Send + Sync), hint: Ipv4Addr) -> Locator {
178 let raw = uc.local_locator();
179 // Keep the port from the bound socket.
180 let port = raw.port;
181 // V6 resolution: with a `::` bind, announce `::1` (loopback) as a
182 // sensible default reachability. Cross-host v6 is its own sprint
183 // (needs a v6 interface probe analogous to the v4 path below).
184 if raw.kind == LocatorKind::UdpV6 || raw.kind == LocatorKind::Tcpv6 {
185 let all_zero = raw.address.iter().all(|b| *b == 0);
186 if all_zero {
187 let mut loopback_addr = [0u8; 16];
188 loopback_addr[15] = 1;
189 return match raw.kind {
190 LocatorKind::Tcpv6 => Locator::tcp_v6(loopback_addr, port),
191 _ => Locator::udp_v6(loopback_addr, port),
192 };
193 }
194 return raw;
195 }
196 // V4 resolution: only meaningful for UDPv4/TCPv4 locators with an
197 // UNSPECIFIED bind. For SHM, return raw — the locator kind has its
198 // own pairing resolution (its own sprint).
199 if raw.kind != LocatorKind::UdpV4 && raw.kind != LocatorKind::Tcpv4 {
200 return raw;
201 }
202 // Extract the address — only the last 4 bytes are the IPv4.
203 let ip = Ipv4Addr::new(
204 raw.address[12],
205 raw.address[13],
206 raw.address[14],
207 raw.address[15],
208 );
209 if !ip.is_unspecified() {
210 return raw;
211 }
212 // Helper: construct a locator with the original kind (UdpV4 or
213 // Tcpv4) and the now-resolved v4 address.
214 let to_locator = |octets: [u8; 4]| -> Locator {
215 match raw.kind {
216 LocatorKind::Tcpv4 => Locator::tcp_v4(octets, port),
217 _ => Locator::udp_v4(octets, port),
218 }
219 };
220 // Interface pinning: an explicitly set interface
221 // (`ZERODDS_INTERFACE` / `RuntimeConfig.multicast_interface`) takes
222 // **precedence over the route probe**. On multi-homed hosts (VPN/
223 // Docker/macOS bridge100) the probe might otherwise pick the wrong
224 // source IP and announce an unreachable address → discovery fails.
225 if !hint.is_unspecified() {
226 return to_locator(hint.octets());
227 }
228 // Probe: temporary socket, "connect" to 192.0.2.1 (RFC 5737
229 // TEST-NET-1, guaranteed non-routable). connect only sets the routing
230 // table — no packet goes out.
231 if let Ok(probe) =
232 std::net::UdpSocket::bind(std::net::SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
233 {
234 if probe
235 .connect(std::net::SocketAddrV4::new(Ipv4Addr::new(192, 0, 2, 1), 7))
236 .is_ok()
237 {
238 if let Ok(std::net::SocketAddr::V4(local)) = probe.local_addr() {
239 let resolved = local.ip();
240 if !resolved.is_unspecified() {
241 return to_locator(resolved.octets());
242 }
243 }
244 }
245 }
246 // Fallback: loopback (the pin hint is already handled above). Not
247 // ideal, but better than 0.0.0.0 as a locator (at least routable on
248 // the same host).
249 to_locator([127, 0, 0, 1])
250}
251
252/// Converts a `core::time::Duration` (std) to a `zerodds_qos::Duration`
253/// (spec 2^-32 fraction encoding). Saturates on overflow — `i32::MAX`
254/// seconds suffices for over 60 years of lease.
255fn qos_duration_from_std(d: Duration) -> QosDuration {
256 let secs = i32::try_from(d.as_secs()).unwrap_or(i32::MAX);
257 let nanos = d.subsec_nanos();
258 // The spec fraction is 2^-32 s; from nanos back via (nanos << 32) / 1e9.
259 let fraction = ((u64::from(nanos)) << 32) / 1_000_000_000u64;
260 QosDuration {
261 seconds: secs,
262 fraction: fraction as u32,
263 }
264}
265
266/// Converts a `zerodds_qos::Duration` to nanoseconds (0 = INFINITE,
267/// "no monitoring"). `seconds` is i32 — we clamp to non-negative.
268fn qos_duration_to_nanos(d: zerodds_qos::Duration) -> u64 {
269 if d.is_infinite() {
270 return 0;
271 }
272 let secs = d.seconds.max(0) as u64;
273 // fraction is 2^-32 s, i.e. nanos = fraction * 1e9 / 2^32.
274 let frac_nanos = ((d.fraction as u64) * 1_000_000_000u64) >> 32;
275 secs.saturating_mul(1_000_000_000u64)
276 .saturating_add(frac_nanos)
277}
278
279/// Human-readable name of a QoS policy id (Spec OMG DDS 1.4 §2.2.3,
280/// PSM ids from [`crate::psm_constants::qos_policy_id`]). Used for the
281/// C2 "loud instead of silent" log on an incompatible QoS match, so that
282/// it states in plain text *which* policy prevented the match.
283#[must_use]
284fn qos_policy_id_name(pid: u32) -> &'static str {
285 use crate::psm_constants::qos_policy_id as qid;
286 match pid {
287 qid::DURABILITY => "DURABILITY",
288 qid::PRESENTATION => "PRESENTATION",
289 qid::DEADLINE => "DEADLINE",
290 qid::LATENCY_BUDGET => "LATENCY_BUDGET",
291 qid::OWNERSHIP => "OWNERSHIP",
292 qid::OWNERSHIP_STRENGTH => "OWNERSHIP_STRENGTH",
293 qid::LIVELINESS => "LIVELINESS",
294 qid::PARTITION => "PARTITION",
295 qid::RELIABILITY => "RELIABILITY",
296 qid::DESTINATION_ORDER => "DESTINATION_ORDER",
297 qid::DURABILITY_SERVICE => "DURABILITY_SERVICE",
298 qid::TYPE_CONSISTENCY_ENFORCEMENT => "TYPE_CONSISTENCY_ENFORCEMENT",
299 qid::DATA_REPRESENTATION => "DATA_REPRESENTATION",
300 _ => "OTHER",
301 }
302}
303
304/// RTPS serialized-payload header for user samples: `CDR_LE`
305/// (PLAIN_CDR / XCDR1, little-endian) + options=0. Spec OMG RTPS 2.5
306/// §9.4.2.13.
307///
308/// Prepended to every user payload before it goes into the DATA
309/// submessage — without this header, vendor readers (Cyclone / Fast-DDS)
310/// refuse to deliver the sample.
311///
312/// **Why `0x01` (XCDR1) and not `0x07` (XCDR2):** the C++ PSM codegen
313/// (`dds/topic/xcdr2.hpp`) aligns 8-byte primitives to `sizeof` — that
314/// is the PLAIN_CDR/XCDR1 rule, NOT XCDR2 (which requires
315/// `min(sizeof,4)`). ZeroDDS therefore effectively produces an XCDR1
316/// layout; the encapsulation header must declare that honestly,
317/// otherwise the peer reads the body with the wrong alignment (e.g.
318/// OpenDDS' `dds_demarshal` fails). Full XCDR2 support is a separate
319/// codegen feature.
320pub const USER_PAYLOAD_ENCAP: [u8; 4] = [0x00, 0x01, 0x00, 0x00];
321
322/// Encapsulation header for the user payload, based on the negotiated
323/// DataRepresentation (`offer_first`: the **first** element of the
324/// writer's offer list = the wire format actually emitted by the writer)
325/// and the type extensibility. The header MUST honestly declare the body
326/// encoding produced by the codegen, otherwise the peer (e.g.
327/// FastDDS/OpenDDS XCDR2-only reader) reads the body with the wrong
328/// alignment or wrongly expects a DHEADER.
329///
330/// DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 (little-endian variant):
331/// XCDR1 final/appendable -> CDR_LE `0x0001`
332/// XCDR1 mutable -> PL_CDR_LE `0x0003`
333/// XCDR2 final -> PLAIN_CDR2_LE `0x0007`
334/// XCDR2 appendable -> D_CDR2_LE `0x0009`
335/// XCDR2 mutable -> PL_CDR2_LE `0x000b`
336#[must_use]
337fn user_payload_encap(
338 offer_first: i16,
339 ext: zerodds_types::qos::ExtensibilityForRepr,
340 big_endian: bool,
341) -> [u8; 4] {
342 use zerodds_rtps::publication_data::data_representation as dr;
343 use zerodds_types::qos::ExtensibilityForRepr::{Appendable, Final, Mutable};
344 let id: u8 = match (offer_first, ext) {
345 (dr::XCDR2, Final) => 0x07,
346 (dr::XCDR2, Appendable) => 0x09,
347 (dr::XCDR2, Mutable) => 0x0b,
348 // XCDR1: appendable is treated like final (Tab.59: XCDR1 has no
349 // dedicated APPENDABLE encoding).
350 (dr::XCDR, Mutable) => 0x03,
351 // (dr::XCDR, Final|Appendable) as well as XML/unknown -> CDR_LE.
352 _ => 0x01,
353 };
354 // RTPS 2.5 §10.5: the `_LE` representation ids are odd, the matching `_BE`
355 // ones are the even predecessor (CDR_BE 0x00, CDR2_BE 0x06, D_CDR2_BE 0x08,
356 // PL_CDR2_BE 0x0a). Clearing the low bit converts LE -> BE.
357 let id = if big_endian { id & 0xFE } else { id };
358 [0x00, id, 0x00, 0x00]
359}
360
361/// Stack PoolBuffer cap for the small-sample path in
362/// [`DcpsRuntime::write_user_sample`]. A 1.5 KiB payload + 4 B encap
363/// header fit through the framing without touching the heap.
364const SMALL_FRAME_CAP: usize = 1536;
365
366/// Small-sample hot-path helper: frames `USER_PAYLOAD_ENCAP` + payload
367/// into a stack `PoolBuffer<SMALL_FRAME_CAP>` and hands the slice to the
368/// writer. No Vec/Box/Rc/Arc allocation in this function — verified by
369/// the `dds_no_realloc_in_hot_path` lint.
370///
371/// zerodds-lint: hot-path-realloc-free
372fn write_user_sample_pooled(
373 writer: &mut ReliableWriter,
374 payload: &[u8],
375 now: Duration,
376 encap: &[u8; 4],
377) -> Result<Vec<zerodds_rtps::message_builder::OutboundDatagram>> {
378 let mut frame = zerodds_foundation::PoolBuffer::<SMALL_FRAME_CAP>::new();
379 frame
380 .extend_from_slice(encap)
381 .map_err(|_| DdsError::WireError {
382 message: String::from("user encap framing"),
383 })?;
384 frame
385 .extend_from_slice(payload)
386 .map_err(|_| DdsError::WireError {
387 message: String::from("user payload framing"),
388 })?;
389 // Hot path: only DATA, NO HEARTBEAT. Cyclone DDS rate-limits the
390 // HB piggyback (≥100 µs spacing, or a packet boundary) — so it does
391 // NOT send an HB per write. At 14k writes/sec this would fire 14k
392 // unnecessary submessages and (with an unaligned payload) 14k extra
393 // sendto syscalls. Periodic HBs are handled by the tick loop (every
394 // `heartbeat_period` ms, default 100 ms); we no longer attach `_now`
395 // to `last_heartbeat`, because we emit nothing.
396 let _ = now;
397 // RTPS-F1: attach the wall-clock source timestamp so the peer can populate
398 // SampleInfo.source_timestamp + honour DESTINATION_ORDER = BY_SOURCE_TIMESTAMP
399 // (DDSI-RTPS §8.7.3). The writer prepends an INFO_TS before the DATA.
400 let source_ts = crate::time::time_to_he_timestamp(crate::time::get_current_time());
401 writer
402 .write_stamped(frame.as_slice(), Some(source_ts))
403 .map_err(|_| DdsError::WireError {
404 message: String::from("user writer encode"),
405 })
406}
407
408/// Choice of transport for DCPS user traffic. Discovery (SPDP/SEDP)
409/// remains UDPv4 multicast independently of this.
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411#[non_exhaustive]
412pub enum UserTransportKind {
413 /// UDP IPv4 (default).
414 UdpV4,
415 /// UDP IPv6.
416 UdpV6,
417 /// TCP IPv4 (DDS-TCP-PSM `LOCATOR_KIND_TCPV4`).
418 TcpV4,
419 /// TCP IPv6 (DDS-TCP-PSM `LOCATOR_KIND_TCPV6`).
420 TcpV6,
421 /// POSIX shared memory (same-host). Only with the `same-host-shm`
422 /// feature.
423 #[cfg(feature = "same-host-shm")]
424 Shm,
425 /// Unix domain socket (same-host, container-friendly). Only with the
426 /// `same-host-uds` feature.
427 #[cfg(feature = "same-host-uds")]
428 Uds,
429 /// TSN L2 transport (AF_PACKET, RTPS direct on Ethernet, EtherType
430 /// 0x88B5). Only with the `tsn-live` feature on Linux. Interface/VLAN/
431 /// PCP via the env vars `ZERODDS_TSN_IFACE`/`_VLAN`/`_PCP`.
432 #[cfg(all(feature = "tsn-live", target_os = "linux"))]
433 Tsn,
434}
435
436/// Maps the `ZERODDS_USER_TRANSPORT` env var to a [`UserTransportKind`].
437/// `None` if unset or unknown — the caller then falls back to UDPv4.
438fn parse_user_transport_env() -> Option<UserTransportKind> {
439 match std::env::var("ZERODDS_USER_TRANSPORT").ok()?.as_str() {
440 "UDPv4" => Some(UserTransportKind::UdpV4),
441 "UDPv6" => Some(UserTransportKind::UdpV6),
442 "TCPv4" => Some(UserTransportKind::TcpV4),
443 "TCPv6" => Some(UserTransportKind::TcpV6),
444 #[cfg(feature = "same-host-shm")]
445 "SHM" => Some(UserTransportKind::Shm),
446 #[cfg(feature = "same-host-uds")]
447 "UDS" => Some(UserTransportKind::Uds),
448 #[cfg(all(feature = "tsn-live", target_os = "linux"))]
449 "TSN" => Some(UserTransportKind::Tsn),
450 _ => None,
451 }
452}
453
454/// Result of [`select_user_transport`]: the user-traffic transport plus
455/// an optional `TcpTransport` accept handle (only for TCP).
456type UserTransportSelection = (
457 Arc<dyn Transport + Send + Sync>,
458 Option<Arc<zerodds_transport_tcp::TcpTransport>>,
459);
460
461/// Binds the user-traffic transport for the selected
462/// [`UserTransportKind`]. Discovery (SPDP/SEDP) runs separately over
463/// UDPv4 multicast; this transport carries only the DCPS user traffic.
464///
465/// Additionally returns an optional `TcpTransport` accept handle: TCP has
466/// no implicit accept thread in the constructor, so the caller starts an
467/// `accept_one` worker for it.
468#[cfg_attr(
469 not(any(feature = "same-host-shm", feature = "same-host-uds")),
470 allow(unused_variables)
471)]
472fn select_user_transport(
473 kind: UserTransportKind,
474 guid_prefix: GuidPrefix,
475 domain_id: i32,
476 pinned: Ipv4Addr,
477) -> Result<UserTransportSelection> {
478 match kind {
479 UserTransportKind::UdpV4 => {
480 // Interface pinning: bind to the pinned IPv4 (egress + receive
481 // on exactly this interface), otherwise `0.0.0.0` (auto).
482 let udp = UdpTransport::bind_v4(pinned, 0)
483 .map_err(|_| DdsError::TransportError {
484 label: "user unicast bind (UDPv4)",
485 })?
486 .with_timeout(Some(Duration::from_secs(1)))
487 .map_err(|_| DdsError::TransportError {
488 label: "user unicast set_timeout (UDPv4)",
489 })?;
490 Ok((Arc::new(udp), None))
491 }
492 UserTransportKind::UdpV6 => {
493 let udp = UdpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
494 .map_err(|_| DdsError::TransportError {
495 label: "user unicast bind (UDPv6)",
496 })?
497 .with_timeout(Some(Duration::from_secs(1)))
498 .map_err(|_| DdsError::TransportError {
499 label: "user unicast set_timeout (UDPv6)",
500 })?;
501 Ok((Arc::new(udp), None))
502 }
503 UserTransportKind::TcpV4 => {
504 // Interface pinning analogous to UDPv4.
505 let tcp = zerodds_transport_tcp::TcpTransport::bind_v4(pinned, 0).map_err(|_| {
506 DdsError::TransportError {
507 label: "user unicast bind (TCPv4)",
508 }
509 })?;
510 let arc = Arc::new(tcp);
511 let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
512 Ok((dynamic, Some(arc)))
513 }
514 UserTransportKind::TcpV6 => {
515 let tcp =
516 zerodds_transport_tcp::TcpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
517 .map_err(|_| DdsError::TransportError {
518 label: "user unicast bind (TCPv6)",
519 })?;
520 let arc = Arc::new(tcp);
521 let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
522 Ok((dynamic, Some(arc)))
523 }
524 #[cfg(feature = "same-host-shm")]
525 UserTransportKind::Shm => {
526 // local_id = guid_prefix (12 bytes) + 4-byte domain id so that
527 // separate domains get separate segments (no cross-domain
528 // collisions).
529 let mut local_id = [0u8; 16];
530 local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
531 local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
532 let shm = crate::shm_user::ShmUserTransport::new(
533 local_id,
534 zerodds_transport_shm::posix::ShmConfig::default(),
535 );
536 Ok((Arc::new(shm), None))
537 }
538 #[cfg(feature = "same-host-uds")]
539 UserTransportKind::Uds => {
540 // local_id = guid_prefix (12 bytes) + 4-byte domain id — the
541 // peer resolves this id from the announced UDS locator into the
542 // same socket path.
543 let mut local_id = [0u8; 16];
544 local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
545 local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
546 // recv_timeout analogous to the UDP path: the recv loop must
547 // periodically check the stop flag (otherwise a thread hang on
548 // shutdown on a blocking recv).
549 let uds_cfg = zerodds_transport_uds::UdsConfig {
550 recv_timeout: Some(Duration::from_secs(1)),
551 ..zerodds_transport_uds::UdsConfig::default()
552 };
553 let uds =
554 zerodds_transport_uds::UdsTransport::bind(local_id, uds_cfg).map_err(|_| {
555 DdsError::TransportError {
556 label: "user unicast bind (UDS)",
557 }
558 })?;
559 Ok((Arc::new(uds), None))
560 }
561 #[cfg(all(feature = "tsn-live", target_os = "linux"))]
562 UserTransportKind::Tsn => {
563 // Interface/VLAN/PCP via env (TSN needs a concrete interface;
564 // not bindable to 0.0.0.0 like UDP/TCP). recv_timeout 1s
565 // analogous to UDP for the stop-flag check.
566 let iface =
567 std::env::var("ZERODDS_TSN_IFACE").map_err(|_| DdsError::TransportError {
568 label: "ZERODDS_TSN_IFACE not set (TSN transport)",
569 })?;
570 let vlan = std::env::var("ZERODDS_TSN_VLAN")
571 .ok()
572 .and_then(|s| s.parse::<u16>().ok())
573 .unwrap_or(0);
574 let pcp = std::env::var("ZERODDS_TSN_PCP")
575 .ok()
576 .and_then(|s| s.parse::<u8>().ok())
577 .unwrap_or(0);
578 let tsn = zerodds_transport_tsn::socket::TsnTransport::bind(
579 &iface,
580 vlan,
581 pcp,
582 Some(Duration::from_secs(1)),
583 )
584 .map_err(|_| DdsError::TransportError {
585 label: "user unicast bind (TSN)",
586 })?;
587 Ok((Arc::new(tsn), None))
588 }
589 }
590}
591
592/// Configuration for the runtime. Exposed via DomainParticipant factory
593/// methods.
594#[derive(Clone)]
595pub struct RuntimeConfig {
596 /// Tick period of the event loop. Default 50 ms.
597 pub tick_period: Duration,
598 /// SPDP announce period. Default 5 s.
599 pub spdp_period: Duration,
600 /// C3 WiFi-robust discovery — number of initial SPDP announces sent at the
601 /// fast [`Self::initial_announce_period`] cadence (instead of `spdp_period`)
602 /// while no peer is yet discovered. Default
603 /// [`DEFAULT_INITIAL_ANNOUNCE_COUNT`]. `0` disables the burst (legacy
604 /// single-announce-then-`spdp_period` behaviour).
605 pub initial_announce_count: u32,
606 /// Period between initial-announcement-burst SPDP sends. Default
607 /// [`DEFAULT_INITIAL_ANNOUNCE_PERIOD`].
608 pub initial_announce_period: Duration,
609 /// SPDP multicast group (IPv4). Default 239.255.0.1 (Spec §9.6.1.4.1).
610 pub spdp_multicast_group: Ipv4Addr,
611 /// Interface address for the multicast join. Default 0.0.0.0 (the
612 /// kernel picks the default interface).
613 pub multicast_interface: Ipv4Addr,
614
615 /// C1: whether SPDP beacons are sent via multicast. Default `true`
616 /// (spec behavior). `false` (env `ZERODDS_NO_MULTICAST`) → pure
617 /// unicast discovery via [`Self::initial_peers`], not a single
618 /// multicast packet — for networks that drop multicast (WiFi/cloud
619 /// VPC), and for a rigorous multicast-free discovery proof.
620 pub spdp_multicast_send: bool,
621
622 /// A1: **discovery-server** mode. When `true`, this participant relays the
623 /// raw SPDP (participant-locator) announcements between the clients that
624 /// point their [`Self::initial_peers`] at it — so N clients discover each
625 /// other through one well-known address instead of an O(N²) peer list or
626 /// multicast. Crucially it relays **only SPDP** (participant discovery);
627 /// SEDP (endpoint discovery, incl. dynamically-created ROS-2 Action
628 /// endpoints) then happens **directly peer-to-peer** between the real
629 /// participants — which is exactly why ROS-2 Actions work over it, unlike a
630 /// SEDP-proxying discovery server. Default `false`. Plain discovery only
631 /// (DDS-Security secured discovery-server relay is a follow-up).
632 pub discovery_server: bool,
633
634 /// C3: max reassemblable sample size (DoS cap of the fragment
635 /// assembler). Larger samples are silently discarded. The rtps
636 /// default was 1 MiB (the phase-1 assumption "large images = no
637 /// use case") — too small for ROS PointCloud2/Image (often several
638 /// MB). Default here 16 MiB; env `ZERODDS_MAX_SAMPLE_BYTES` (bytes)
639 /// overrides. Still a deliberate DoS guard, just ROS-realistic.
640 pub max_reassembly_sample_bytes: usize,
641
642 /// C1 multicast-free discovery: unicast initial-peer locators to
643 /// which SPDP beacons are sent **in addition** to multicast. Default
644 /// empty (= pure multicast behavior as before). Populated via
645 /// [`RuntimeConfig::default`] from the env `ZERODDS_PEERS` (comma
646 /// list of `ip` or `ip:port`). An `ip` without a port is expanded to
647 /// the well-known SPDP unicast ports of participant indices 0..N
648 /// (see [`expand_initial_peer`]).
649 pub initial_peers: Vec<Locator>,
650
651 /// Transport for DCPS user traffic. `None` (default) → fall back to
652 /// the env var `ZERODDS_USER_TRANSPORT`, otherwise UDPv4. Discovery
653 /// (SPDP/SEDP) remains UDPv4 multicast independently of this. Ignored when
654 /// [`Self::user_transports`] is non-empty.
655 pub user_transport: Option<UserTransportKind>,
656
657 /// Preference-ordered set of transports for DCPS user traffic. When
658 /// non-empty, the runtime builds a [`LayeredUserTransport`](crate::layered_transport::LayeredUserTransport)
659 /// over all of them: each datagram is routed to the first transport whose
660 /// locator kind matches the destination (so list the fast/local transport
661 /// first — e.g. `[Shm, UdpV4]` — and the fallback last), and receives are
662 /// multiplexed from all of them. Empty (default) → single-transport via
663 /// [`Self::user_transport`].
664 pub user_transports: alloc::vec::Vec<UserTransportKind>,
665
666 /// Optional security gate. Active only with the `security` feature.
667 /// When set, UDP outbound messages are pulled through
668 /// [`SharedSecurityGate::transform_outbound`], and inbound messages
669 /// through [`SharedSecurityGate::transform_inbound_from`] (peer key
670 /// from RTPS header bytes 8..20).
671 #[cfg(feature = "security")]
672 pub security: Option<std::sync::Arc<zerodds_security_runtime::SharedSecurityGate>>,
673 /// Optional LoggingPlugin for security events. Called by the inbound
674 /// path when packets are dropped due to a policy violation, tampering
675 /// or a legacy block.
676 #[cfg(feature = "security")]
677 pub security_logger: Option<std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin>>,
678
679 /// Multi-interface bindings. Empty → `user_unicast` is the only
680 /// outbound socket (legacy behavior). Non-empty →
681 /// `DcpsRuntime::start` builds a dedicated UDP socket per spec and the
682 /// writer tick loop routes to the matching socket per destination
683 /// locator.
684 #[cfg(feature = "security")]
685 pub interface_bindings: Vec<InterfaceBindingSpec>,
686
687 /// `true` → the SPDP beacon additionally announces the 12 secure
688 /// discovery bits (16..27, DDS-Security 1.2 §7.4.7.1). Default
689 /// `false` — only standard bits are announced. Set by the DCPS
690 /// factory once a PolicyEngine is configured. This flag is available
691 /// even without the `security` feature, so that tests can check bit
692 /// presence without activating the whole crypto crate.
693 pub announce_secure_endpoints: bool,
694
695 /// FastDDS interop: run the reliable secure SPDP channel (0xff0101c2/c7,
696 /// `ENTITYID_SPDP_RELIABLE_BUILTIN_PARTICIPANT_SECURE_*`). FastDDS announces
697 /// its full secured participant data (identity_token/security_info) over
698 /// this channel and gates the crypto-token reciprocation/endpoint matching
699 /// on it; cyclone does NOT need it (cyclone↔zerodds runs without). Default off
700 /// — enable only for FastDDS cross-vendor.
701 pub enable_secure_spdp: bool,
702
703 /// WLP-Tick-Periode (Writer-Liveliness-Protocol, RTPS 2.5 §8.4.13).
704 /// `Duration::ZERO` → default `participant_lease_duration / 3`
705 /// (spec recommendation: three misses before the reader marks the
706 /// writer as not-alive). A direct override enables aggressive
707 /// tests.
708 pub wlp_period: Duration,
709
710 /// Lease duration announced in the SPDP beacon as
711 /// `PARTICIPANT_LEASE_DURATION` (spec default 100 s). Also used as the
712 /// basis for the AUTOMATIC WLP tick (`wlp_period =
713 /// participant_lease_duration / 3` if `wlp_period == Duration::ZERO`).
714 pub participant_lease_duration: Duration,
715
716 /// USER_DATA bytes of the participant (DDS 1.4 §2.2.3.1
717 /// `UserDataQosPolicy`). Announced in the SPDP beacon as PID_USER_DATA
718 /// (DDSI-RTPS §9.6.3.2) and exposed on the receiver side in
719 /// `ParticipantBuiltinTopicData.user_data`. Default empty.
720 pub user_data: Vec<u8>,
721
722 /// Observability sink. Default is `null_sink()` — each event emit is
723 /// then a direct return without allocation on the consumer side.
724 /// Consumers inject e.g.
725 /// [`zerodds_foundation::observability::StderrJsonSink`] (JSON lines
726 /// for Vector/fluentd/Datadog) or their own OTLP bridge.
727 pub observability: zerodds_foundation::observability::SharedSink,
728
729 /// Sprint D.5d lever C — RT pinning + priority. Linux-only; on
730 /// macOS/Windows the hooks are no-ops.
731 ///
732 /// SCHED_FIFO priority (1-99) for the three recv workers (SPDP MC,
733 /// metatraffic, user data). `None` = default scheduler (CFS).
734 /// `Some(80)` is the spec recommendation for real-time paths. Requires
735 /// `CAP_SYS_NICE` or an `RLIMIT_RTPRIO`-permitted user.
736 pub recv_thread_priority: Option<i32>,
737
738 /// Like [`Self::recv_thread_priority`], but for the tick worker.
739 pub tick_thread_priority: Option<i32>,
740
741 /// CPU affinity mask for the recv workers. `None` = no affinity (the
742 /// kernel schedules freely). A list of CPU indices, e.g.
743 /// `vec![2, 3]` for cores 2+3. Set via `sched_setaffinity`; all three
744 /// recv threads share the same mask.
745 pub recv_thread_cpus: Option<Vec<usize>>,
746
747 /// Like [`Self::recv_thread_cpus`], but for the tick worker.
748 pub tick_thread_cpus: Option<Vec<usize>>,
749
750 /// Opt-3 (Spec `zerodds-zero-copy-1.0` §9): number of additional
751 /// user-data recv workers that listen on the same port as
752 /// `user_unicast` via `SO_REUSEPORT`. `0` (default) = only the primary
753 /// `recv_user_data_loop` worker. Under high recv load the pool scales
754 /// linearly with cores (kernel flow hashing distributes incoming
755 /// datagrams). Recommended values: 1-3 additional workers per CPU
756 /// core.
757 pub extra_recv_threads: usize,
758
759 /// D.5g — default DataRepresentation list announced in SEDP
760 /// PublicationData and SEDP SubscriptionData, when not overridden
761 /// per-writer/reader (UserWriterConfig/UserReaderConfig).
762 ///
763 /// **Important**: per strict spec (XTypes 1.3 §7.6.3.1.2) the first
764 /// element is the writer's "offered" and must be in the reader's
765 /// "accepted" list for a match to happen. Default `[XCDR1, XCDR2]` =
766 /// legacy-first → max interop with the RTI Connext Shapes Demo
767 /// (XCDR1-only). Pure-XCDR2 deployments can switch this to `[XCDR2]`
768 /// or `[XCDR2, XCDR1]` for bandwidth efficiency and
769 /// @appendable/@mutable support.
770 ///
771 /// Empty (`vec![]`) is interpreted per spec as `[XCDR1]`.
772 pub data_representation_offer: Vec<i16>,
773
774 /// D.5g — default match mode for DataRepresentation negotiation.
775 ///
776 /// `Strict` (XTypes 1.3 §7.6.3.1.2 normative): writer.first ∈
777 /// reader.list = match. `Tolerant` (industry norm): any overlap =
778 /// match, picks the first overlap as the wire format.
779 ///
780 /// Default `Tolerant` because Cyclone DDS and FastDDS match this way —
781 /// maximizes interop. The strict setting is only meaningful for
782 /// formal spec-compliance tests.
783 pub data_rep_match_mode: zerodds_rtps::publication_data::data_representation::DataRepMatchMode,
784
785 /// zerodds-async-1.0 §4 — when `true`, `start()` does **not** spawn the
786 /// dedicated `zdds-tick` std::thread. The periodic tick (SPDP announce,
787 /// SEDP/WLP, deadline/lifespan/liveliness) must then be driven externally
788 /// via [`DcpsRuntime::tick_driver`]. Used by the async API's
789 /// `spawn_in_tokio`, which multiplexes many participants' tick loops onto
790 /// a tokio runtime instead of one thread each. Default `false` (internal
791 /// thread, unchanged behaviour). The recv worker threads are unaffected —
792 /// they block on socket recv and stay regardless.
793 pub external_tick: bool,
794
795 /// D.5e Phase 3 — when `true`, `start()` drives the periodic tick via the
796 /// event-driven deadline scheduler ([`crate::scheduler`]) instead of the
797 /// fixed-`tick_period` poll: the worker parks until the next due deadline
798 /// (SPDP announce, or a fine floor while user endpoints/QoS timers are
799 /// active) or until a write/recv `raise` wakes it — no busy-poll, lower idle
800 /// CPU, lower tail latency. The work done per wake is the **unchanged**
801 /// `run_tick_iteration` (identical wire output + cadence — cross-vendor
802 /// safe). **Default `true`** since D.5e Phase C (2026-06-14) — set
803 /// `ZERODDS_SCHEDULER_TICK=0` or this field to `false` for the classic
804 /// fixed-period `tick_loop`. Mutually exclusive with `external_tick`
805 /// (external wins).
806 pub scheduler_tick: bool,
807}
808
809/// Configuration entry for a physical or logical network interface.
810///
811/// A binding describes an outbound socket: which IP/port it binds to,
812/// which `NetInterface` class the interface represents, and which IP
813/// range counts as "associated peers" (routing match).
814#[cfg(feature = "security")]
815#[derive(Clone, Debug)]
816pub struct InterfaceBindingSpec {
817 /// Name for diagnostics + log attribution (e.g. `"eth0"`, `"tun0"`,
818 /// `"lo"`).
819 pub name: String,
820 /// Bind address. `0.0.0.0` leaves the interface to the kernel.
821 pub bind_addr: Ipv4Addr,
822 /// Bind port. `0` = ephemeral.
823 pub bind_port: u16,
824 /// Interface class — feeds into the PolicyEngine context.
825 pub kind: NetInterface,
826 /// Destination IP range this binding is responsible for. Example:
827 /// `127.0.0.0/8` for loopback. A target whose IP lies in this range is
828 /// routed to this binding.
829 pub subnet: IpRange,
830 /// If `true`: this binding is used when **no** other subnet match
831 /// applies. Exactly one entry should have `default = true` (usually
832 /// the WAN binding).
833 pub default: bool,
834}
835
836/// Fully bound interface with its UDP socket.
837#[cfg(feature = "security")]
838struct InterfaceBinding {
839 spec: InterfaceBindingSpec,
840 socket: Arc<UdpTransport>,
841}
842
843/// Pool of per-interface UDP sockets with target-based routing.
844///
845/// Decision:
846/// 1. Iterates over all bindings; the first whose subnet contains the
847/// target wins.
848/// 2. If no match and a default binding exists → default path.
849/// 3. No match + no default → `None`, the caller drops.
850#[cfg(feature = "security")]
851struct OutboundSocketPool {
852 bindings: Vec<InterfaceBinding>,
853 default_idx: Option<usize>,
854}
855
856#[cfg(feature = "security")]
857impl OutboundSocketPool {
858 fn bind_all(specs: &[InterfaceBindingSpec]) -> Result<Self> {
859 let mut bindings = Vec::with_capacity(specs.len());
860 for spec in specs {
861 let socket = UdpTransport::bind_v4(spec.bind_addr, spec.bind_port).map_err(|_| {
862 DdsError::TransportError {
863 label: "interface-binding bind_v4 failed",
864 }
865 })?;
866 // Short read timeout so that the per-interface inbound poll in
867 // the event loop becomes non-blocking. 5 ms is small enough not
868 // to create latency elsewhere (the tick period defaults to
869 // 50 ms), but large enough to amortize context switches.
870 let socket = socket
871 .with_timeout(Some(Duration::from_millis(5)))
872 .map_err(|_| DdsError::TransportError {
873 label: "interface-binding set_timeout failed",
874 })?;
875 bindings.push(InterfaceBinding {
876 spec: spec.clone(),
877 socket: Arc::new(socket),
878 });
879 }
880 let default_idx = bindings.iter().position(|b| b.spec.default);
881 Ok(Self {
882 bindings,
883 default_idx,
884 })
885 }
886
887 /// Returns `(socket, NetInterface class)` for a destination locator.
888 /// `None` if neither a subnet match nor a default binding exists.
889 fn route(&self, target: &Locator) -> Option<(&Arc<UdpTransport>, NetInterface)> {
890 let ip = ipv4_from_locator(target)?;
891 let addr = core::net::IpAddr::V4(core::net::Ipv4Addr::from(ip));
892 for b in &self.bindings {
893 if b.spec.subnet.contains(&addr) {
894 return Some((&b.socket, b.spec.kind.clone()));
895 }
896 }
897 let idx = self.default_idx?;
898 let b = self.bindings.get(idx)?;
899 Some((&b.socket, b.spec.kind.clone()))
900 }
901}
902
903/// True if the locator is routable over the user-data transport
904/// (trait object). Accepts UDPv4, UDPv6, TCPv4, Shm. The concrete
905/// transport (UdpTransport/TcpTransport/ShmUserTransport) then returns
906/// `UnsupportedLocator` for kinds it does not itself speak;
907/// the filter here only prevents sending to clearly non-IP/SHM
908/// locators like UDS (for which we have no transport plugin).
909fn is_routable_user_locator(loc: &Locator) -> bool {
910 matches!(
911 loc.kind,
912 LocatorKind::UdpV4
913 | LocatorKind::UdpV6
914 | LocatorKind::Tcpv4
915 | LocatorKind::Tcpv6
916 | LocatorKind::Shm
917 | LocatorKind::Uds
918 | LocatorKind::Tsn
919 )
920}
921
922/// Computes the user-endpoint `EndpointSecurityInfo` mask from the governance
923/// protection kinds (DDS-Security 1.2 §10.4.1.2.6 / §9.4.2.4). The wire mask
924/// MUST match cyclone/FastDDS/OpenDDS byte-exactly, otherwise the peer rejects
925/// the endpoint match with "security_attributes mismatch".
926///
927/// - metadata=SIGN/ENCRYPT → IS_SUBMESSAGE_PROTECTED (+ plugin SUBMESSAGE_ENCRYPTED on ENCRYPT)
928/// - data=SIGN → IS_PAYLOAD_PROTECTED
929/// - data=ENCRYPT → IS_PAYLOAD_PROTECTED | **IS_KEY_PROTECTED** (+ plugin PAYLOAD_ENCRYPTED)
930/// - liveliness=SIGN/ENCRYPT → **IS_LIVELINESS_PROTECTED** (§9.4.1.3: per-endpoint!)
931/// - topic enable_discovery_protection → IS_DISCOVERY_PROTECTED
932///
933/// is_key_protected follows §10.4.1.2.6 exclusively from the **DATA** protection
934/// and only on ENCRYPT — NOT from the metadata protection. is_liveliness_protected
935/// in contrast MUST be on every user endpoint as soon as liveliness_protection is active;
936/// cyclone compares the mask at endpoint match and otherwise rejects with
937/// "security_attributes mismatch" (0x..30 vs 0x..70).
938#[cfg(feature = "security")]
939fn compute_user_endpoint_attrs(
940 meta: ProtectionLevel,
941 data: ProtectionLevel,
942 discovery_protected: bool,
943 liveliness_protected: bool,
944 read_protected: bool,
945 write_protected: bool,
946) -> zerodds_rtps::endpoint_security_info::EndpointSecurityInfo {
947 use zerodds_rtps::endpoint_security_info::{EndpointSecurityInfo, attrs, plugin_attrs};
948 let mut a = attrs::IS_VALID;
949 let mut p = plugin_attrs::IS_VALID;
950 if read_protected {
951 a |= attrs::IS_READ_PROTECTED;
952 }
953 if write_protected {
954 a |= attrs::IS_WRITE_PROTECTED;
955 }
956 if meta != ProtectionLevel::None {
957 a |= attrs::IS_SUBMESSAGE_PROTECTED;
958 }
959 if meta == ProtectionLevel::Encrypt {
960 p |= plugin_attrs::IS_SUBMESSAGE_ENCRYPTED;
961 }
962 if data != ProtectionLevel::None {
963 a |= attrs::IS_PAYLOAD_PROTECTED;
964 }
965 if data == ProtectionLevel::Encrypt {
966 a |= attrs::IS_KEY_PROTECTED;
967 p |= plugin_attrs::IS_PAYLOAD_ENCRYPTED;
968 }
969 if discovery_protected {
970 a |= attrs::IS_DISCOVERY_PROTECTED;
971 }
972 if liveliness_protected {
973 a |= attrs::IS_LIVELINESS_PROTECTED;
974 }
975 EndpointSecurityInfo {
976 endpoint_security_attributes: a,
977 plugin_endpoint_security_attributes: p,
978 }
979}
980
981#[cfg(all(test, feature = "security"))]
982mod endpoint_attr_tests {
983 use super::compute_user_endpoint_attrs;
984 use zerodds_rtps::endpoint_security_info::attrs;
985 use zerodds_security_runtime::ProtectionLevel;
986
987 fn mask(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
988 compute_user_endpoint_attrs(meta, data, false, false, false, false)
989 .endpoint_security_attributes
990 }
991
992 fn mask_liv(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
993 compute_user_endpoint_attrs(meta, data, false, true, false, false)
994 .endpoint_security_attributes
995 }
996
997 #[test]
998 fn liveliness_protected_sets_0x40_per_spec_9_4_1_3() {
999 use ProtectionLevel::{Encrypt, None};
1000 let v = attrs::IS_VALID;
1001 let pay = attrs::IS_PAYLOAD_PROTECTED;
1002 let key = attrs::IS_KEY_PROTECTED;
1003 let liv = attrs::IS_LIVELINESS_PROTECTED;
1004 // liveliness=ENCRYPT + data=ENCRYPT → 0x..70 (cyclone's value at match).
1005 assert_eq!(mask_liv(None, Encrypt), v | pay | key | liv);
1006 // without liveliness → 0x..30, NO 0x40.
1007 assert_eq!(mask(None, Encrypt), v | pay | key);
1008 assert_eq!(mask_liv(None, None), v | liv);
1009 }
1010
1011 #[test]
1012 fn key_protected_follows_data_encrypt_per_spec_10_4_1_2_6() {
1013 use ProtectionLevel::{Encrypt, None, Sign};
1014 let v = attrs::IS_VALID;
1015 let sub = attrs::IS_SUBMESSAGE_PROTECTED;
1016 let pay = attrs::IS_PAYLOAD_PROTECTED;
1017 let key = attrs::IS_KEY_PROTECTED;
1018 // §10.4.1.2.6: is_key_protected follows ONLY data=ENCRYPT.
1019 // data=ENCRYPT → PAYLOAD|KEY (= cyclone's 0x30 in the common subset).
1020 assert_eq!(mask(None, Encrypt), v | pay | key);
1021 // data=SIGN → PAYLOAD, NO KEY.
1022 assert_eq!(mask(None, Sign), v | pay);
1023 // data=NONE → no payload/key bits.
1024 assert_eq!(mask(None, None), v);
1025 // KEY does NOT depend on metadata: meta=ENCRYPT/data=NONE → only SUBMESSAGE.
1026 assert_eq!(mask(Encrypt, None), v | sub);
1027 // meta=ENCRYPT + data=ENCRYPT → SUBMESSAGE|PAYLOAD|KEY (0x38).
1028 assert_eq!(mask(Encrypt, Encrypt), v | sub | pay | key);
1029 }
1030}
1031
1032/// Unicast targets for the WLP heartbeat fan-out (M-2): per discovered peer the
1033/// `metatraffic_unicast_locator` (fallback `default_unicast_locator`), filtered
1034/// to routable kinds. WLP is metatraffic (DDSI-RTPS §8.4.13); in multicast-
1035/// free environments (container/cloud) the pure multicast pulse never reaches the
1036/// peer reader → the lease expires although the peer is alive. The additional
1037/// unicast fan-out follows the SEDP locator model.
1038fn wlp_unicast_targets(peers: &[zerodds_discovery::spdp::DiscoveredParticipant]) -> Vec<Locator> {
1039 peers
1040 .iter()
1041 .filter_map(|dp| {
1042 dp.data
1043 .metatraffic_unicast_locator
1044 .or(dp.data.default_unicast_locator)
1045 })
1046 .filter(is_routable_user_locator)
1047 .collect()
1048}
1049
1050/// Extracts the IPv4 address from a `Locator` (UDP-V4).
1051/// `None` for SHM/UDS/IPv6.
1052#[cfg(feature = "security")]
1053fn ipv4_from_locator(loc: &Locator) -> Option<[u8; 4]> {
1054 if loc.kind != LocatorKind::UdpV4 {
1055 return None;
1056 }
1057 Some([
1058 loc.address[12],
1059 loc.address[13],
1060 loc.address[14],
1061 loc.address[15],
1062 ])
1063}
1064
1065impl core::fmt::Debug for RuntimeConfig {
1066 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1067 let mut dbg = f.debug_struct("RuntimeConfig");
1068 dbg.field("tick_period", &self.tick_period)
1069 .field("spdp_period", &self.spdp_period)
1070 .field("spdp_multicast_group", &self.spdp_multicast_group)
1071 .field("multicast_interface", &self.multicast_interface);
1072 #[cfg(feature = "security")]
1073 {
1074 dbg.field("security", &self.security.as_ref().map(|_| "<gate>"));
1075 dbg.field(
1076 "security_logger",
1077 &self.security_logger.as_ref().map(|_| "<logger>"),
1078 );
1079 }
1080 dbg.finish()
1081 }
1082}
1083
1084impl Default for RuntimeConfig {
1085 fn default() -> Self {
1086 // Env hook for bench tuning: ZERODDS_TICK_PERIOD_MS=N → overrides
1087 // the 5ms default. High (e.g. 1000) relieves the write hot path of
1088 // the periodic HB/tick overhead and makes spread spikes from tick
1089 // preemption visible. Production: do not set; the default 5 ms is
1090 // spec-compliant.
1091 let tick = std::env::var("ZERODDS_TICK_PERIOD_MS")
1092 .ok()
1093 .and_then(|s| s.parse::<u64>().ok())
1094 .map(Duration::from_millis)
1095 .unwrap_or(DEFAULT_TICK_PERIOD);
1096 // C3 WiFi-robust discovery — initial-announcement burst. Env overrides:
1097 // `ZERODDS_INITIAL_ANNOUNCE_COUNT` (0 disables) +
1098 // `ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS`.
1099 let initial_announce_count = std::env::var("ZERODDS_INITIAL_ANNOUNCE_COUNT")
1100 .ok()
1101 .and_then(|s| s.parse::<u32>().ok())
1102 .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_COUNT);
1103 let initial_announce_period = std::env::var("ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS")
1104 .ok()
1105 .and_then(|s| s.parse::<u64>().ok())
1106 .map(Duration::from_millis)
1107 .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_PERIOD);
1108 Self {
1109 tick_period: tick,
1110 spdp_period: DEFAULT_SPDP_PERIOD,
1111 initial_announce_count,
1112 initial_announce_period,
1113 // Env override `ZERODDS_SPDP_MC_GROUP` (IPv4) of the SPDP
1114 // multicast group. Two processes with different groups do NOT
1115 // see each other via multicast → enables a multicast-free C1
1116 // e2e proof (discovery then only via ZERODDS_PEERS). Default is
1117 // the spec group.
1118 spdp_multicast_group: std::env::var("ZERODDS_SPDP_MC_GROUP")
1119 .ok()
1120 .and_then(|s| s.parse::<Ipv4Addr>().ok())
1121 .unwrap_or_else(|| Ipv4Addr::from(SPDP_DEFAULT_MULTICAST_ADDRESS)),
1122 // Interface pinning (Cyclone `NetworkInterface`/FastDDS
1123 // whitelist equivalent): `ZERODDS_INTERFACE=<ipv4>` forces
1124 // announce + bind on this interface. Default UNSPECIFIED = auto
1125 // (route probe). Critical on multi-homed hosts (VPN/Docker/
1126 // macOS bridge100), where the auto choice may announce the
1127 // wrong interface.
1128 multicast_interface: std::env::var("ZERODDS_INTERFACE")
1129 .ok()
1130 .and_then(|s| s.parse::<Ipv4Addr>().ok())
1131 .unwrap_or(Ipv4Addr::UNSPECIFIED),
1132 // Multicast send on by default; `ZERODDS_NO_MULTICAST` (any
1133 // non-empty value) turns it off → pure unicast discovery.
1134 spdp_multicast_send: std::env::var("ZERODDS_NO_MULTICAST")
1135 .map(|v| v.is_empty())
1136 .unwrap_or(true),
1137 // A1: off by default; env `ZERODDS_DISCOVERY_SERVER` (any non-empty
1138 // value) or `RuntimeConfig::discovery_server()` turns the relay on.
1139 discovery_server: std::env::var("ZERODDS_DISCOVERY_SERVER")
1140 .map(|v| !v.is_empty())
1141 .unwrap_or(false),
1142 // C3: 16 MiB default (suitable for ROS PointCloud2/Image),
1143 // env override `ZERODDS_MAX_SAMPLE_BYTES`.
1144 max_reassembly_sample_bytes: std::env::var("ZERODDS_MAX_SAMPLE_BYTES")
1145 .ok()
1146 .and_then(|s| s.parse::<usize>().ok())
1147 .unwrap_or(16 * 1024 * 1024),
1148 // Programmatic default empty. The env `ZERODDS_PEERS` is
1149 // expanded domain-aware only in `DcpsRuntime::start` and merged
1150 // with this field into the effective peer list.
1151 initial_peers: Vec::new(),
1152 user_transport: None,
1153 user_transports: alloc::vec::Vec::new(),
1154 #[cfg(feature = "security")]
1155 security: None,
1156 #[cfg(feature = "security")]
1157 security_logger: None,
1158 #[cfg(feature = "security")]
1159 interface_bindings: Vec::new(),
1160 announce_secure_endpoints: false,
1161 // Env hook for bench/FastDDS interop: ZERODDS_SECURE_SPDP=1 turns
1162 // on the reliable secure SPDP channel (0xff0101). Production sets this
1163 // explicitly via the SecurityProfile/config.
1164 enable_secure_spdp: std::env::var("ZERODDS_SECURE_SPDP").ok().as_deref() == Some("1"),
1165 wlp_period: Duration::ZERO,
1166 participant_lease_duration: Duration::from_secs(100),
1167 user_data: Vec::new(),
1168 observability: zerodds_foundation::observability::null_sink(),
1169 recv_thread_priority: None,
1170 tick_thread_priority: None,
1171 recv_thread_cpus: None,
1172 tick_thread_cpus: None,
1173 extra_recv_threads: 0,
1174 // D.5g — default `[XCDR1, XCDR2]` (legacy-first, max interop).
1175 // Env-var override `ZERODDS_DATA_REPR_OFFER` as a comma list
1176 // ("XCDR1", "XCDR2", "XCDR1,XCDR2", "XCDR2,XCDR1"). Cross-vendor
1177 // benches against strict-matching vendors (RTI) need XCDR2-only
1178 // so that every wire match happens.
1179 data_representation_offer: parse_data_repr_offer_env().unwrap_or_else(|| {
1180 zerodds_rtps::publication_data::data_representation::DEFAULT_OFFER.to_vec()
1181 }),
1182 data_rep_match_mode:
1183 zerodds_rtps::publication_data::data_representation::DataRepMatchMode::default(),
1184 external_tick: false,
1185 // D.5e Phase 3 — the event-driven deadline-heap scheduler is the
1186 // DEFAULT tick (Phase C, 2026-06-14): it parks until the next due
1187 // deadline / a write-recv raise instead of polling every 5 ms (~17×
1188 // fewer idle iterations, lower tail latency, identical wire output).
1189 // Verified cross-vendor secured (data-enc + rtps-enc all pairs) +
1190 // same_host_e2e + latency_assertions on codepit. Escape hatch:
1191 // `ZERODDS_SCHEDULER_TICK=0` restores the classic fixed-period
1192 // `tick_loop`.
1193 scheduler_tick: std::env::var("ZERODDS_SCHEDULER_TICK")
1194 .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false")))
1195 .unwrap_or(true),
1196 }
1197 }
1198}
1199
1200impl RuntimeConfig {
1201 /// Apply a [`SecurityBundle`](zerodds_security_runtime::SecurityBundle):
1202 /// wires its security-event logger into [`Self::security_logger`] and, if
1203 /// the bundle carries a [`SecurityProfile`](zerodds_security_runtime::SecurityProfile),
1204 /// its gate into [`Self::security`]. Convenience for the common
1205 /// `SecurityBundle::builder()…build()` flow so callers don't have to set
1206 /// the two fields by hand.
1207 #[cfg(feature = "security")]
1208 #[must_use]
1209 pub fn with_security_bundle(
1210 mut self,
1211 bundle: &zerodds_security_runtime::SecurityBundle,
1212 ) -> Self {
1213 if let Some(logger) = bundle.logging_plugin() {
1214 self.security_logger = Some(logger);
1215 }
1216 if let Some(profile) = bundle.security_profile() {
1217 self.security = Some(profile.gate.clone());
1218 }
1219 self
1220 }
1221
1222 /// Materialize a security-event logger from `dds.sec.log.*` properties and
1223 /// wire it into [`Self::security_logger`]. This is the DDS-Security
1224 /// spec-style alternative to handing a logger object in directly (see
1225 /// [`Self::with_security_bundle`]): the participant carries
1226 /// `dds.sec.log.plugin = "stderr,jsonl"` (+ `dds.sec.log.*` parameters) on
1227 /// its [`PropertyQosPolicy`](zerodds_qos::PropertyQosPolicy), and the
1228 /// runtime builds the fan-out logger from them.
1229 ///
1230 /// No-op when `dds.sec.log.plugin` is absent. Errors if a selected sink is
1231 /// misconfigured (e.g. `jsonl` without `dds.sec.log.jsonl.path`).
1232 #[cfg(feature = "security")]
1233 pub fn with_security_log_properties(
1234 mut self,
1235 property: &zerodds_qos::PropertyQosPolicy,
1236 ) -> core::result::Result<Self, zerodds_security_logging::LogConfigError> {
1237 let pairs: alloc::vec::Vec<(&str, &str)> = property.iter().collect();
1238 if let Some(logger) = zerodds_security_logging::logging_plugin_from_properties(&pairs)? {
1239 self.security_logger = Some(alloc::sync::Arc::from(logger));
1240 }
1241 Ok(self)
1242 }
1243
1244 /// C4: robotics-capable defaults for **out-of-the-box ROS-2 interop**.
1245 /// Saves the manual env tuning otherwise needed for real ROS-2 nodes.
1246 /// Specifically, compared to [`RuntimeConfig::default`]:
1247 /// - **`data_representation_offer = [XCDR1, XCDR2]`**: `rmw_cyclonedds`/
1248 /// `rmw_fastrtps` write XCDR1 for final/simple types (e.g.
1249 /// `std_msgs/String`). An XCDR2-only reader does not match an XCDR1
1250 /// writer — so the ROS reader here offers both legacy-first
1251 /// (tolerant match is already the default). This is the clean,
1252 /// ROS-specific variant of the `ZERODDS_DATA_REPR_OFFER` env
1253 /// workaround, WITHOUT changing the global `DEFAULT_OFFER`
1254 /// (XCDR2-only, deliberately for FastDDS/OpenDDS XCDR2 readers).
1255 ///
1256 /// The ROS-realistic reassembly cap (16 MiB, PointCloud2/Image) is
1257 /// already the global default and is carried over here.
1258 #[must_use]
1259 pub fn ros_defaults() -> Self {
1260 use zerodds_rtps::publication_data::data_representation as dr;
1261 Self {
1262 data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1263 ..Self::default()
1264 }
1265 }
1266
1267 /// C6 multi-robot / WAN / cross-subnet profile.
1268 ///
1269 /// A named profile for fleets that span subnets, the cloud, or WiFi —
1270 /// environments that drop IP multicast, so SPDP discovery cannot rely on
1271 /// the multicast beacon. It is the [`ros_defaults`](Self::ros_defaults)
1272 /// representation offer **plus**:
1273 ///
1274 /// - **Multicast-free discovery** (`spdp_multicast_send = false`):
1275 /// participants find each other purely through unicast initial peers,
1276 /// regardless of the `ZERODDS_NO_MULTICAST` env. Set the peers via
1277 /// `ZERODDS_PEERS` (a comma list of `ip` or `ip:port`); a port-less
1278 /// `ip` is expanded to the well-known SPDP unicast ports of the first
1279 /// N participant indices (`ZERODDS_MAX_PEER_PARTICIPANTS`).
1280 /// - **WAN-tolerant liveliness**: a longer participant lease (300 s vs
1281 /// the 100 s spec default) so transient cross-subnet RTT spikes or
1282 /// brief link drops do not trigger a false liveliness loss.
1283 ///
1284 /// **Domain isolation** is the caller's lever: pass a fleet-dedicated
1285 /// `domain_id` to [`DcpsRuntime::start`] to keep robots off the default
1286 /// domain 0. The profile deliberately does not pick a domain for you.
1287 ///
1288 /// ```
1289 /// use zerodds_dcps::runtime::RuntimeConfig;
1290 /// let cfg = RuntimeConfig::multi_robot();
1291 /// assert!(!cfg.spdp_multicast_send); // unicast-only discovery
1292 /// ```
1293 pub fn multi_robot() -> Self {
1294 use zerodds_rtps::publication_data::data_representation as dr;
1295 Self {
1296 data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1297 spdp_multicast_send: false,
1298 participant_lease_duration: Duration::from_secs(300),
1299 ..Self::default()
1300 }
1301 }
1302
1303 /// A1 — **discovery-server** profile (the server side). Multicast-free, with
1304 /// the SPDP relay on ([`Self::discovery_server`]): clients point their
1305 /// `initial_peers`/`ZERODDS_PEERS` at this one well-known address and the
1306 /// server bridges their participant discovery, so N clients find each other
1307 /// without an O(N²) peer list or multicast. SEDP (incl. ROS-2 Action
1308 /// endpoints) stays direct peer-to-peer — no SEDP proxy, no Action breakage.
1309 ///
1310 /// Clients run with [`RuntimeConfig::multi_robot`] (or any multicast-free
1311 /// config) and set `ZERODDS_PEERS` to the server's address.
1312 ///
1313 /// ```
1314 /// use zerodds_dcps::runtime::RuntimeConfig;
1315 /// let server = RuntimeConfig::discovery_server();
1316 /// assert!(server.discovery_server);
1317 /// assert!(!server.spdp_multicast_send); // unicast-only
1318 /// ```
1319 pub fn discovery_server() -> Self {
1320 Self {
1321 discovery_server: true,
1322 ..Self::multi_robot()
1323 }
1324 }
1325}
1326
1327/// Parse the `ZERODDS_DATA_REPR_OFFER` env var. Values: "XCDR1", "XCDR2",
1328/// or a comma list. None if the env var is missing or invalid.
1329fn parse_data_repr_offer_env() -> Option<Vec<i16>> {
1330 let s = std::env::var("ZERODDS_DATA_REPR_OFFER").ok()?;
1331 parse_data_repr_offer_str(&s)
1332}
1333
1334/// Computes the **well-known** SPDP unicast discovery port for a
1335/// domain + participant index. Formula (DDSI-RTPS 2.5 §9.6.1.4.1):
1336/// port = PB + DG·domain + d1 + PG·pid = 7400 + 250·domain + 10 + 2·pid
1337///
1338/// This lets a configured unicast initial peer (multicast-free discovery)
1339/// reach a participant deterministically WITHOUT having found it via
1340/// multicast first. Defined locally in `dcps` to avoid touching
1341/// `crates/rtps` (spec constants as literals).
1342#[must_use]
1343fn spdp_unicast_port(domain_id: u32, participant_id: u32) -> u32 {
1344 7400 + 250 * domain_id + 10 + 2 * participant_id
1345}
1346
1347/// Default number of participant indices a port-less initial peer is
1348/// expanded to (Cyclone equivalent: `MaxAutoParticipantIndex`). The
1349/// beacon thereby reaches the first N participants of the peer host via
1350/// their well-known SPDP unicast ports. Overridable via the env
1351/// `ZERODDS_MAX_PEER_PARTICIPANTS` (e.g. for dense multi-robot / >10
1352/// participants-per-host scenarios). Cap 120 (= the well-known-port
1353/// allocation window).
1354const INITIAL_PEER_MAX_PARTICIPANTS: u32 = 10;
1355
1356/// Effective peer-expansion limit: env `ZERODDS_MAX_PEER_PARTICIPANTS`
1357/// or [`INITIAL_PEER_MAX_PARTICIPANTS`], clamped to 1..=120.
1358fn initial_peer_max_participants() -> u32 {
1359 std::env::var("ZERODDS_MAX_PEER_PARTICIPANTS")
1360 .ok()
1361 .and_then(|s| s.parse::<u32>().ok())
1362 .unwrap_or(INITIAL_PEER_MAX_PARTICIPANTS)
1363 .clamp(1, 120)
1364}
1365
1366/// C1 multicast-free discovery: parses the env `ZERODDS_PEERS` (comma
1367/// list of `ip` or `ip:port`) into SPDP unicast initial-peer locators for
1368/// `domain_id`. Empty/invalid → empty list.
1369fn parse_initial_peers_env(domain_id: u32) -> Vec<Locator> {
1370 let mut out = Vec::new();
1371 let max = initial_peer_max_participants();
1372 if let Ok(s) = std::env::var("ZERODDS_PEERS") {
1373 for entry in s.split(',') {
1374 expand_initial_peer(entry.trim(), domain_id, max, &mut out);
1375 }
1376 }
1377 out
1378}
1379
1380/// Expands a single peer spec into locator(s) and appends them to `out`.
1381/// `ip:port` → exact locator. Just `ip` → well-known SPDP unicast ports
1382/// of participant indices `0..max_participants` (Spec §9.6.1.4.1).
1383/// Invalid specs are ignored.
1384fn expand_initial_peer(spec: &str, domain_id: u32, max_participants: u32, out: &mut Vec<Locator>) {
1385 if spec.is_empty() {
1386 return;
1387 }
1388 if let Some((ip_s, port_s)) = spec.rsplit_once(':') {
1389 if let (Ok(ip), Ok(port)) = (ip_s.parse::<Ipv4Addr>(), port_s.parse::<u16>()) {
1390 out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1391 return;
1392 }
1393 }
1394 if let Ok(ip) = spec.parse::<Ipv4Addr>() {
1395 for pid in 0..max_participants {
1396 if let Ok(port) = u16::try_from(spdp_unicast_port(domain_id, pid)) {
1397 out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1398 }
1399 }
1400 }
1401}
1402
1403/// Pure parser for the `ZERODDS_DATA_REPR_OFFER` syntax (testable without
1404/// env). Returns the DataRepresentationId list with the **spec values**
1405/// `XCDR=0`, `XCDR2=2` (XTypes 1.3 §7.6.3.1.2) — NOT version numbers.
1406/// `None` on empty/invalid input.
1407fn parse_data_repr_offer_str(s: &str) -> Option<Vec<i16>> {
1408 use zerodds_rtps::publication_data::data_representation as dr;
1409 let mut out = Vec::new();
1410 for tok in s.split(',').map(str::trim) {
1411 let v = match tok.to_ascii_uppercase().as_str() {
1412 "XCDR1" | "XCDR" | "1" => dr::XCDR,
1413 "XCDR2" | "2" => dr::XCDR2,
1414 _ => return None,
1415 };
1416 out.push(v);
1417 }
1418 if out.is_empty() { None } else { Some(out) }
1419}
1420
1421// ---------------------------------------------------------------------------
1422// Security-gate helpers
1423// ---------------------------------------------------------------------------
1424
1425/// Pull outbound UDP bytes through the security gate (when configured).
1426/// Without the `security` feature or without a gate: pass-through (clone
1427/// as Vec).
1428///
1429/// Errors in the gate are logged silently and the packet is **not** sent —
1430/// better to drop than leak plaintext.
1431/// DDS-Security 8.4.2.4: the RTPS message protection (message-level SRTPS)
1432/// does NOT apply to bootstrap traffic that must flow BEFORE the participant
1433/// crypto-key exchange: SPDP (participant discovery, to everyone) and the
1434/// ParticipantStatelessMessage (auth handshake). Wrapping them in SRTPS would
1435/// mean a not-yet-authenticated peer could not decrypt them
1436/// (no key) -> discovery/auth breaks (match timeout pub=0 sub=0). Detection
1437/// via the writer EntityId of the DATA/DATA_FRAG submessages.
1438#[cfg(feature = "security")]
1439fn rtps_message_protection_exempt(
1440 bytes: &[u8],
1441 discovery_plain: bool,
1442 liveliness_plain: bool,
1443) -> bool {
1444 use zerodds_rtps::wire_types::EntityId;
1445 // Bootstrap endpoints (§8.4.2.4): SPDP/Stateless/Volatile ALWAYS flow
1446 // plain (before/during key exchange resp. their own submessage protection).
1447 // Discovery plane (SEDP pub/sub, TypeLookup) is plain when discovery_
1448 // protection_kind=NONE; WLP (ParticipantMessage) plain when liveliness_
1449 // protection_kind=NONE. cyclone<->cyclone reference capture: under rtps_
1450 // protection=ENCRYPT + discovery=NONE cyclone sends the ENTIRE discovery
1451 // plane (DATA+HEARTBEAT+ACKNACK) PLAINTEXT — only user DATA is SRTPS-
1452 // wrapped. ZeroDDS must mirror this, otherwise it drops cyclone's plain
1453 // SubscriptionData as legacy_blocked -> no user-endpoint match.
1454 let entity_exempt = |e: EntityId| -> bool {
1455 matches!(
1456 e,
1457 EntityId::SPDP_BUILTIN_PARTICIPANT_WRITER
1458 | EntityId::SPDP_BUILTIN_PARTICIPANT_READER
1459 | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
1460 | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
1461 | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
1462 | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
1463 ) || (discovery_plain
1464 && matches!(
1465 e,
1466 EntityId::SEDP_BUILTIN_PUBLICATIONS_WRITER
1467 | EntityId::SEDP_BUILTIN_PUBLICATIONS_READER
1468 | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_WRITER
1469 | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_READER
1470 | EntityId::TL_SVC_REQ_WRITER
1471 | EntityId::TL_SVC_REQ_READER
1472 | EntityId::TL_SVC_REPLY_WRITER
1473 | EntityId::TL_SVC_REPLY_READER
1474 ))
1475 || (liveliness_plain
1476 && matches!(
1477 e,
1478 EntityId::BUILTIN_PARTICIPANT_MESSAGE_WRITER
1479 | EntityId::BUILTIN_PARTICIPANT_MESSAGE_READER
1480 ))
1481 };
1482 let Ok(parsed) = decode_datagram(bytes) else {
1483 return false;
1484 };
1485 // Datagram exempt if it has at least one relevant submessage AND
1486 // ALL relevant ones are exempt (.all) — otherwise a bundled
1487 // exempt+non-exempt datagram leaks the protection-worthy submessage.
1488 let relevant: alloc::vec::Vec<bool> = parsed
1489 .submessages
1490 .iter()
1491 .filter_map(|sm| match sm {
1492 ParsedSubmessage::Data(d) => {
1493 Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1494 }
1495 ParsedSubmessage::DataFrag(d) => {
1496 Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1497 }
1498 ParsedSubmessage::Heartbeat(h) => {
1499 Some(entity_exempt(h.reader_id) || entity_exempt(h.writer_id))
1500 }
1501 ParsedSubmessage::AckNack(a) => {
1502 Some(entity_exempt(a.reader_id) || entity_exempt(a.writer_id))
1503 }
1504 ParsedSubmessage::Gap(g) => {
1505 Some(entity_exempt(g.reader_id) || entity_exempt(g.writer_id))
1506 }
1507 ParsedSubmessage::NackFrag(n) => {
1508 Some(entity_exempt(n.reader_id) || entity_exempt(n.writer_id))
1509 }
1510 // SEC_PREFIX (Kx-Volatile, inner writer-id encrypted) -> exempt.
1511 ParsedSubmessage::Unknown { id: 0x31, .. } => Some(true),
1512 // Framing (INFO_DST/INFO_TS/...) -> neutral.
1513 _ => None,
1514 })
1515 .collect();
1516 !relevant.is_empty() && relevant.iter().all(|&b| b)
1517}
1518
1519#[cfg(feature = "security")]
1520fn secure_outbound_bytes<'a>(
1521 rt: &DcpsRuntime,
1522 bytes: &'a [u8],
1523) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1524 match &rt.config.security {
1525 // OUTBOUND is spec-strict (DDS-Security 8.4.2.4 Table 27 is_rtps_protected):
1526 // under rtps_protection the ENTIRE RTPS message is SRTPS-wrapped; ONLY the
1527 // "separate messages" (SPDP/Stateless/Volatile) flow plain. SEDP/WLP/
1528 // TypeLookup are NOT among them and must be wrapped — independent
1529 // of discovery_/liveliness_protection (those are orthogonal submessage layers).
1530 // -> discovery_plain=false, liveliness_plain=false forces the wrap.
1531 // OpenDDS' RtpsUdpReceiveStrategy::check_encoded otherwise drops every plain SEDP
1532 // as "Full message requires protection". cyclone does take the shortcut
1533 // (sends SEDP plain), but accepts wrapped SEDP inbound without issue.
1534 // The INBOUND path (secure_inbound_bytes) deliberately stays lenient and still
1535 // accepts cyclone's plain SEDP — the asymmetry is intentional.
1536 Some(gate) if rtps_message_protection_exempt(bytes, false, false) => {
1537 let _ = gate;
1538 Some(alloc::borrow::Cow::Borrowed(bytes))
1539 }
1540 Some(gate) => gate
1541 .transform_outbound(bytes)
1542 .ok()
1543 .map(alloc::borrow::Cow::Owned),
1544 None => Some(alloc::borrow::Cow::Borrowed(bytes)),
1545 }
1546}
1547
1548// Security off: no clone — the caller borrows the datagram bytes
1549// directly (copy 6 of the zero-copy audit eliminated).
1550#[cfg(not(feature = "security"))]
1551fn secure_outbound_bytes<'a>(
1552 _rt: &DcpsRuntime,
1553 bytes: &'a [u8],
1554) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1555 Some(alloc::borrow::Cow::Borrowed(bytes))
1556}
1557
1558/// Pull inbound UDP bytes through the security gate.
1559///
1560/// Expects an RTPS header with the GuidPrefix at bytes 8..20.
1561/// `None` → drop the packet.
1562///
1563/// Security: drop reasons are forwarded, differentiated, to the
1564/// configured `LoggingPlugin`:
1565/// * `Malformed` → `Error`
1566/// * `LegacyBlocked` → `Error`
1567/// * `PolicyViolation` → `Warning` (possible tampering)
1568/// * `CryptoError` → `Warning` (tag mismatch, replay etc.)
1569#[cfg(feature = "security")]
1570fn secure_inbound_bytes<'a>(
1571 rt: &DcpsRuntime,
1572 bytes: &'a [u8],
1573 iface: &NetInterface,
1574) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1575 use zerodds_security_runtime::{InboundVerdict, LogLevel};
1576 let Some(gate) = &rt.config.security else {
1577 return Some(alloc::borrow::Cow::Borrowed(bytes));
1578 };
1579 // DDS-Security 8.4.2.4 (symmetric to outbound): SPDP/Stateless are
1580 // message-protection-exempt and ALWAYS arrive plain (also from cyclone). Without
1581 // this exception classify_inbound discards plain SPDP on the WAN iface under
1582 // rtps_protection as LegacyBlocked -> no discovery (match timeout).
1583 {
1584 let looks_srtps = bytes.len() > 20usize && bytes[20usize] == 0x33;
1585 if !looks_srtps
1586 && rtps_message_protection_exempt(
1587 bytes,
1588 gate.discovery_protection().unwrap_or(ProtectionLevel::None)
1589 == ProtectionLevel::None,
1590 gate.liveliness_protection()
1591 .unwrap_or(ProtectionLevel::None)
1592 == ProtectionLevel::None,
1593 )
1594 {
1595 // SRTPS-exempt. BUT metadata_protection user DATA carries per-submessage
1596 // SEC_PREFIX/BODY/POSTFIX (§9.5.3.3, NO SRTPS) — that must still be
1597 // decrypted per-endpoint here, otherwise the reader gets the
1598 // SEC wrapper instead of the DATA. Volatile-Kx-SEC fails with None
1599 // (key_id not in user-remote_by_key_id) -> unchanged for the
1600 // Volatile handler in the metatraffic loop.
1601 if walk_submessages(bytes)
1602 .iter()
1603 .any(|(id, _, _)| *id == SMID_SEC_PREFIX)
1604 {
1605 let mut pk = [0u8; 12];
1606 pk.copy_from_slice(&bytes[8..20]);
1607 if let Some(mut dg) = unprotect_user_datagram(rt, bytes, &pk) {
1608 match unprotect_user_payload(rt, &dg) {
1609 PayloadDecode::Decoded(clear) => dg = clear,
1610 PayloadDecode::Failed => return None,
1611 PayloadDecode::NotEncrypted => {}
1612 }
1613 return Some(alloc::borrow::Cow::Owned(dg));
1614 }
1615 }
1616 return Some(alloc::borrow::Cow::Borrowed(bytes));
1617 }
1618 }
1619 let verdict = gate.classify_inbound(bytes, iface);
1620 let category = verdict.category();
1621 let (level, message): (LogLevel, String) = match &verdict {
1622 InboundVerdict::Accept(out) => {
1623 // Cross-vendor user DATA: cyclone protects the DATA submessage as a
1624 // SEC_PREFIX/BODY/POSTFIX sequence (metadata_protection=ENCRYPT). Before
1625 // the submessage parse, transform it back with the sender's data key
1626 // (GuidPrefix = bytes[8..20]). `unprotect_user_datagram` returns
1627 // `None` when no SEC_* sequence is present → normal accept path.
1628 // OUTER layer first (metadata_protection, SEC_PREFIX/BODY/
1629 // POSTFIX), then the INNER one (data_protection, encrypted
1630 // SerializedPayload §9.5.3.3.1). Both can be active at once
1631 // (full secure profile); each returns `None` when its layer
1632 // is not present -> then the datagram stays unchanged.
1633 let mut dg: alloc::vec::Vec<u8> = out.clone();
1634 if dg.len() >= 20 {
1635 let mut pk = [0u8; 12];
1636 pk.copy_from_slice(&dg[8..20]);
1637 if let Some(clear) = unprotect_user_datagram(rt, &dg, &pk) {
1638 dg = clear;
1639 }
1640 }
1641 match unprotect_user_payload(rt, &dg) {
1642 PayloadDecode::Decoded(clear) => dg = clear,
1643 // Undecodable encrypted payload -> discard the datagram
1644 // (no ciphertext garbage to the reader; reliable re-send resp. another
1645 // copy delivers the sample later).
1646 PayloadDecode::Failed => return None,
1647 PayloadDecode::NotEncrypted => {}
1648 }
1649 return Some(alloc::borrow::Cow::Owned(dg));
1650 }
1651 InboundVerdict::Malformed => (
1652 LogLevel::Error,
1653 alloc::format!(
1654 "inbound datagram too short ({} bytes, iface={:?})",
1655 bytes.len(),
1656 iface
1657 ),
1658 ),
1659 InboundVerdict::LegacyBlocked => (
1660 LogLevel::Error,
1661 alloc::format!(
1662 "legacy plaintext peer on protected domain \
1663 (iface={iface:?}, allow_unauthenticated_participants=false)"
1664 ),
1665 ),
1666 InboundVerdict::PolicyViolation(msg) => {
1667 (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1668 }
1669 InboundVerdict::CryptoError(msg) => {
1670 (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1671 }
1672 };
1673 if let Some(logger) = &rt.config.security_logger {
1674 // Participant ident: GuidPrefix (or 0-padding for Malformed).
1675 let mut participant = [0u8; 16];
1676 if bytes.len() >= 20 {
1677 participant[..12].copy_from_slice(&bytes[8..20]);
1678 }
1679 logger.log(level, participant, category, &message);
1680 }
1681 None
1682}
1683
1684#[cfg(not(feature = "security"))]
1685fn secure_inbound_bytes<'a>(
1686 _rt: &DcpsRuntime,
1687 bytes: &'a [u8],
1688) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1689 Some(alloc::borrow::Cow::Borrowed(bytes))
1690}
1691
1692/// Default interface class for inbound dispatch when the socket does not
1693/// belong to the `outbound_pool`. In the v1.4 setup (without
1694/// `interface_bindings`), all packets run through `user_unicast` and are
1695/// classified as `Wan` — the most conservative assumption (protection
1696/// rules apply as in the single-interface case).
1697#[cfg(feature = "security")]
1698const DEFAULT_INBOUND_IFACE: NetInterface = NetInterface::Wan;
1699
1700/// Per-reader outbound transform.
1701///
1702/// Looks up in the writer slot which `ProtectionLevel` the matched reader
1703/// expects at the given `target` locator, then pulls the datagram through
1704/// the security gate individually. This way each reader gets a wire
1705/// payload matching its security profile (Legacy=plain, Fast=Sign,
1706/// Secure=Encrypt).
1707///
1708/// Fallback paths:
1709/// * No security gate configured → passthrough.
1710/// * No `locator_to_peer` entry (reader not yet matched via SEDP) →
1711/// `transform_outbound` with the domain rule — that is the homogeneous
1712/// v1.4 path.
1713/// * The gate returns an error → `None` (the caller drops — better no
1714/// plaintext leak).
1715#[cfg(feature = "security")]
1716fn secure_outbound_for_target(
1717 rt: &DcpsRuntime,
1718 writer_eid: EntityId,
1719 bytes: &[u8],
1720 target: &Locator,
1721) -> Option<Vec<u8>> {
1722 let Some(gate) = &rt.config.security else {
1723 return Some(bytes.to_vec());
1724 };
1725 // FU2 S3: fallback level from our own governance (data_protection_
1726 // kind), in case the matched reader did not announce an explicit SEDP
1727 // security_info level. This way user data to an authenticated peer is
1728 // encrypted per our own governance, while SPDP/SEDP metatraffic
1729 // bootstraps plaintext over rtps_protection_kind=NONE.
1730 // Governance `data_protection` is a FLOOR, not a mere fallback: a
1731 // per-reader level can only STRENGTHEN (e.g. legacy plaintext is only
1732 // allowed if the domain policy itself permits plaintext), never fall
1733 // below the domain policy. Otherwise a matched-but-not-authenticated
1734 // peer (foreign CA, SEDP match over plaintext discovery,
1735 // reader_protection=None) leaks plaintext user data.
1736 let gov_data_level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1737 // metadata_protection (§8.4.2.4 / §9.5.3.3): EVERY writer submessage (DATA,
1738 // HEARTBEAT, GAP) is SEC_PREFIX/BODY/POSTFIX-wrapped per-submessage —
1739 // TARGET-INDEPENDENT, since the per-endpoint writer key is local (the peer fetches
1740 // it via datawriter_crypto_token). Must take effect BEFORE the locator-based reader
1741 // resolution: otherwise tick HEARTBEATs/GAPs to not-yet-locator-
1742 // matched targets fall into the None branch -> with rtps=NONE PLAIN -> leak + no
1743 // reliable recovery (breaks already zero<->zero). data_protection (inner
1744 // payload layer) first, then the outer submessage layer.
1745 if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1746 let inner = if gov_data_level != ProtectionLevel::None {
1747 protect_user_payload(rt, bytes)?
1748 } else {
1749 bytes.to_vec()
1750 };
1751 let meta_sec = protect_user_datagram(rt, &inner)?;
1752 // Under rtps_protection message-level SRTPS MUST additionally go on top —
1753 // BOTH layers, like cyclone<->cyclone. Without it the peer would see the
1754 // metadata-SEC-DATA as "clear submsg from protected src" and discard it.
1755 if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1756 return gate.transform_outbound(&meta_sec).ok();
1757 }
1758 return Some(meta_sec);
1759 }
1760 let resolved = rt.writer_slot(writer_eid).and_then(|arc| {
1761 arc.lock().ok().and_then(|slot| {
1762 let pk = slot.locator_to_peer.get(target).copied()?;
1763 // An EXPLICITLY negotiated per-reader level is respected: a
1764 // legacy-v1.4 reader has reader_protection=None and MUST get plaintext,
1765 // otherwise it cannot decode (heterogeneous domain). Only
1766 // when NO entry exists (matched via plaintext discovery, but
1767 // no level negotiated -> potentially unauthenticated) does the
1768 // governance data_protection FLOOR apply as leak protection.
1769 // Governance data_protection is a FLOOR (§8.4.2.4, memory-documented):
1770 // a per-reader level can only STRENGTHEN, never fall below the domain
1771 // policy. A reader discovered via secure SEDP whose security_info parses
1772 // to `Some(None)` (no is_payload_protected bit detected, discovery=
1773 // ENCRYPT) would otherwise yield level=None -> Some(None) arm -> PLAINTEXT
1774 // leak, although the domain requires data_protection=ENCRYPT (disc-data-
1775 // enc: zerodds sent user DATA without the N-flag -> OpenDDS decode_serialized_
1776 // payload=0 -> no echo). `.max` enforces at least the governance FLOOR.
1777 // With gov=None legacy plaintext (reader_lv) stays allowed.
1778 let level = match slot.reader_protection.get(&pk).copied() {
1779 Some(reader_lv) => reader_lv.max(gov_data_level),
1780 None => gov_data_level,
1781 };
1782 Some((pk, level))
1783 })
1784 });
1785 match resolved {
1786 // Matched reader with Sign/Encrypt: cyclone-conformant SUBMESSAGE
1787 // protection (SEC_PREFIX/BODY/POSTFIX around the DATA submessage, local
1788 // data key) instead of message-level SRTPS — `metadata_protection_kind=
1789 // ENCRYPT`, §9.5.3.3. cyclone decodes with the key sent via datawriter_crypto_
1790 // tokens. `None` level = byte-identical passthrough.
1791 Some((peer_key, level)) if level != ProtectionLevel::None => {
1792 // Layer choice per governance (DDS-Security §8.4.2.4 vs §7.3.7):
1793 // * metadata_protection_kind != NONE -> per-submessage protection
1794 // (`encode_datawriter_submessage`, SEC_PREFIX/BODY/POSTFIX) for
1795 // EVERY writer submessage (DATA, HEARTBEAT, GAP, ...). This is the
1796 // cyclone interop path: cyclone expects HEARTBEAT/GAP SEC_*-
1797 // wrapped too, otherwise its reader never NACKs (no reliable recovery).
1798 // * otherwise (only rtps_protection_kind != NONE) -> message-level SRTPS
1799 // via `transform_outbound_for` (whole message, §7.3.7).
1800 // INNER layer (§9.5.3.3.1): data_protection encrypts ONLY the
1801 // SerializedPayload of each DATA submessage. Applied BEFORE the outer
1802 // submessage/message layer — cyclone-conformant
1803 // nesting (§9.5.3.3): data_protection (inner) + metadata_
1804 // protection (outer). With pure data_protection this is the
1805 // only + complete protection.
1806 let inner: Vec<u8> = if gov_data_level != ProtectionLevel::None {
1807 // Crypto error -> drop instead of leak (None propagated via `?`).
1808 protect_user_payload(rt, bytes)?
1809 } else {
1810 bytes.to_vec()
1811 };
1812 // OUTER layer choice (DDS-Security §8.4.2.4 / §7.3.7):
1813 if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None
1814 {
1815 // metadata_protection -> per-submessage protection (DATA, HEARTBEAT,
1816 // GAP, ...) with the per-endpoint writer key (cyclone interop path).
1817 // Under additional rtps_protection message-level SRTPS MUST go on top
1818 // (both layers) — otherwise "clear submsg from protected src".
1819 match protect_user_datagram(rt, &inner) {
1820 Some(ms)
1821 if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1822 != ProtectionLevel::None =>
1823 {
1824 gate.transform_outbound(&ms).ok()
1825 }
1826 other => other,
1827 }
1828 } else if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1829 != ProtectionLevel::None
1830 {
1831 // rtps_protection -> message-level SRTPS (whole message, §7.3.7),
1832 // per-reader key.
1833 gate.transform_outbound_for(&peer_key, &inner, level).ok()
1834 } else {
1835 // only data_protection -> the payload layer is already the
1836 // complete protection (§9.5.3.3.1). Header/InlineQoS stay
1837 // plaintext, the encrypted payload carries the N-flag.
1838 Some(inner)
1839 }
1840 }
1841 // Matched reader with level None: a legacy-v1.4 reader (explicit
1842 // SEDP legacy or NONE governance) gets byte-identical plaintext —
1843 // message-level SRTPS would make it undecryptable.
1844 Some(_) => {
1845 // Matched reader with data level None: under rtps_protection the
1846 // message MUST still be message-level-SRTPS-wrapped (§8.4.2.4) —
1847 // the data_protection level only controls the payload/submessage layer.
1848 // Without it user DATA/HEARTBEAT leaks plain, although the domain
1849 // requires rtps_protection=ENCRYPT (the peer discards it as legacy).
1850 if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1851 gate.transform_outbound(bytes).ok()
1852 } else {
1853 Some(bytes.to_vec())
1854 }
1855 }
1856 // No locator-resolved reader: multicast/meta bootstrap OR a
1857 // user reader whose locator is (not yet) in `locator_to_peer`
1858 // (e.g. discovered via secure SEDP, discovery_protection=ENCRYPT). The
1859 // data_protection (inner payload layer §9.5.3.3.1) is TARGET-INDEPENDENT
1860 // (local writer key) and MUST still apply for a user writer —
1861 // otherwise under data_protection=ENCRYPT the user DATA leaks PLAINTEXT (N-flag
1862 // missing -> a spec-conformant remote reader never calls `decode_serialized_payload`
1863 // -> no sample, no echo; disc-data-enc stall, source-documented: OpenDDS
1864 // decode_serialized_payload=0). ONLY for user writers — SPDP/SEDP builtin DATA
1865 // must bootstrap plaintext (otherwise undecodable before key exchange).
1866 None => {
1867 use zerodds_rtps::wire_types::EntityKind;
1868 let is_user_writer = matches!(
1869 writer_eid.entity_kind,
1870 EntityKind::UserWriterWithKey | EntityKind::UserWriterNoKey
1871 );
1872 if is_user_writer && gov_data_level != ProtectionLevel::None {
1873 let inner = protect_user_payload(rt, bytes)?;
1874 gate.transform_outbound(&inner).ok()
1875 } else {
1876 gate.transform_outbound(bytes).ok()
1877 }
1878 }
1879 }
1880}
1881
1882#[cfg(not(feature = "security"))]
1883fn secure_outbound_for_target(
1884 _rt: &DcpsRuntime,
1885 _writer_eid: EntityId,
1886 bytes: &[u8],
1887 _target: &Locator,
1888) -> Option<Vec<u8>> {
1889 Some(bytes.to_vec())
1890}
1891
1892/// FU2 S3: data_protection-aware user DATA outbound. Encrypts the
1893/// datagram with the governance `data_protection` level. `transform_outbound_
1894/// for` ignores the `peer_key` and uses the local key — the ciphertext
1895/// is decryptable for EVERY authenticated peer (with our token),
1896/// non-authenticated peers cannot read it. A `None` level falls
1897/// back to message-level (`rtps_protection` resp. passthrough). Used for
1898/// UDP + in-process fastpath + SHM UNIFORMLY, so the
1899/// inproc path is secured too.
1900#[cfg(feature = "security")]
1901fn secure_user_outbound<'a>(
1902 rt: &DcpsRuntime,
1903 bytes: &'a [u8],
1904) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1905 let Some(gate) = &rt.config.security else {
1906 return Some(alloc::borrow::Cow::Borrowed(bytes));
1907 };
1908 let level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1909 if matches!(level, ProtectionLevel::None) {
1910 gate.transform_outbound(bytes)
1911 .ok()
1912 .map(alloc::borrow::Cow::Owned)
1913 } else {
1914 gate.transform_outbound_for(&[0u8; 12], bytes, level)
1915 .ok()
1916 .map(alloc::borrow::Cow::Owned)
1917 }
1918}
1919
1920#[cfg(not(feature = "security"))]
1921fn secure_user_outbound<'a>(
1922 _rt: &DcpsRuntime,
1923 bytes: &'a [u8],
1924) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1925 Some(alloc::borrow::Cow::Borrowed(bytes))
1926}
1927
1928/// Sends `bytes` to `target` on the matching interface socket.
1929/// Falls back to `rt.user_unicast` if no
1930/// pool is configured or no binding matches the target range
1931/// and no default binding is set either.
1932#[cfg(feature = "security")]
1933fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1934 if let Some(pool) = &rt.outbound_pool {
1935 if let Some((socket, _iface)) = pool.route(target) {
1936 let _ = socket.send(target, bytes);
1937 return;
1938 }
1939 }
1940 let _ = rt.user_unicast.send(target, bytes);
1941}
1942
1943#[cfg(not(feature = "security"))]
1944fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1945 let _ = rt.user_unicast.send(target, bytes);
1946}
1947
1948/// User-writer slot in the runtime. Carries ReliableWriter + topic meta +
1949/// fragment size (from QoS).
1950struct UserWriterSlot {
1951 writer: ReliableWriter,
1952 topic_name: String,
1953 type_name: String,
1954 reliable: bool,
1955 durability: zerodds_qos::DurabilityKind,
1956 /// Deadline period in nanoseconds (0 == INFINITE, no monitoring).
1957 deadline_nanos: u64,
1958 /// Last successful `write` relative to `DcpsRuntime::start_instant`.
1959 last_write: Option<Duration>,
1960 /// Counter for missed deadlines (Spec §2.2.4.2.9).
1961 offered_deadline_missed_count: u64,
1962 /// Counter for LivelinessLost detections from the writer's view
1963 /// (Spec §2.2.4.2.10). Incremented in `check_writer_liveliness` on
1964 /// manual-lease overrun. 0 == not monitored.
1965 liveliness_lost_count: u64,
1966 /// Last assert time (manual liveliness). `None` == never.
1967 last_liveliness_assert: Option<Duration>,
1968 /// Per-policy counter for offered_incompatible_qos. Spec
1969 /// §2.2.4.2.4.2 — writer side. Incremented on
1970 /// `wire_writer_to_remote_reader` reject.
1971 offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus,
1972 /// Lifespan duration in nanoseconds (0 == INFINITE, no expiry).
1973 lifespan_nanos: u64,
1974 /// Per sample SN the insert time (relative to start_instant).
1975 /// Removed from front on expiry — SNs are monotonic, lifespan
1976 /// is constant, so the expiry prefix is always front.
1977 sample_insert_times:
1978 alloc::collections::VecDeque<(zerodds_rtps::wire_types::SequenceNumber, Duration)>,
1979 /// Liveliness kind (Automatic / ManualByParticipant / ManualByTopic).
1980 liveliness_kind: zerodds_qos::LivelinessKind,
1981 /// Lease duration in nanoseconds (0 == INFINITE).
1982 liveliness_lease_nanos: u64,
1983 /// Ownership mode.
1984 ownership: zerodds_qos::OwnershipKind,
1985 /// Ownership strength (Spec §2.2.3.2). Mirrored in the same-runtime
1986 /// dispatch into `UserSample::Alive.writer_strength`, so that
1987 /// EXCLUSIVE ownership logic in the reader also works for intra-process
1988 /// loopback.
1989 ownership_strength: i32,
1990 /// Partition list.
1991 partition: Vec<String>,
1992 /// Per-matched-reader ProtectionLevel. Derived at the
1993 /// SEDP match from `sub.security_info`. `None` entries
1994 /// for legacy readers. Empty for writers without matched
1995 /// security peers — then the hot path is unchanged.
1996 #[cfg(feature = "security")]
1997 reader_protection: BTreeMap<[u8; 12], ProtectionLevel>,
1998 /// Mapping Locator → GuidPrefix for the writer tick loop, so that
1999 /// `secure_outbound_for_target` can look up the protection per target
2000 /// without breaking the writer-tick API (`dg.targets` are
2001 /// locator lists today).
2002 #[cfg(feature = "security")]
2003 locator_to_peer: BTreeMap<Locator, [u8; 12]>,
2004 /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the writer type
2005 /// (from `T::TYPE_IDENTIFIER` in `UserWriterConfig`).
2006 type_identifier: zerodds_types::TypeIdentifier,
2007 /// D.5g — per-writer override for the DataRepresentation offer.
2008 /// `None` = runtime default. `Some(vec)` = hardcoded per writer.
2009 data_rep_offer_override: Option<Vec<i16>>,
2010 /// Type extensibility of the writer type (FINAL/APPENDABLE/MUTABLE).
2011 /// Together with the offer `first` element it determines the
2012 /// encapsulation header of the user payload (see
2013 /// [`user_payload_encap`]). Default `Final`; set by codegen/FFI via
2014 /// `set_user_writer_wire_extensibility` when the type
2015 /// is appendable/mutable (relevant for XCDR2 wire: D_CDR2/PL_CDR2).
2016 wire_extensibility: zerodds_types::qos::ExtensibilityForRepr,
2017 /// Emit the big-endian encapsulation variant (`_BE`, RTPS 2.5 §10.5)
2018 /// instead of the little-endian default. Set by the durability service
2019 /// replay path so a big-endian peer's stored sample is re-published with a
2020 /// matching BE encap header (the body bytes are already big-endian). `false`
2021 /// = little-endian (the canonical wire for a normal writer).
2022 big_endian_override: bool,
2023 /// Spec §2.2.3.5 DurabilityService — with Durability=Transient/
2024 /// Persistent the backend holds in addition to the writer's own
2025 /// HistoryCache. On the first late-joiner match in
2026 /// `wire_writer_to_remote_reader` the backend samples are
2027 /// (re-)injected into the HistoryCache, so that the RTPS reliable
2028 /// path delivers them to the reader. `None` for Volatile/
2029 /// TransientLocal (the cache suffices).
2030 durability_backend: Option<alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>>,
2031 /// `true` as soon as the backend has been replayed once into the
2032 /// HistoryCache. Prevents repeated re-injection on further matches.
2033 backend_primed: bool,
2034 /// HISTORY KeepLast depth (DDS 1.4 §2.2.3.18). Per-instance retained-sample
2035 /// depth for the same-runtime durability replay path. Default
2036 /// [`DEFAULT_INTRA_HISTORY_DEPTH`]. KeepAll is modelled as `usize::MAX`.
2037 /// Settable via [`DcpsRuntime::set_user_writer_history_depth`].
2038 history_depth: usize,
2039 /// TRANSIENT_LOCAL retained samples (DDS 1.4 §2.2.3.4) for the
2040 /// same-runtime late-joiner replay path. Holds the *most recent*
2041 /// `history_depth` Alive samples **per instance key** plus any
2042 /// terminal lifecycle marker for an instance. A reader that joins an
2043 /// intra-runtime route AFTER these writes replays this buffer so it sees
2044 /// the retained history (the wire/SEDP path is separate, see
2045 /// `wire_writer_to_remote_reader`). Empty unless durability is
2046 /// TransientLocal or stronger.
2047 retained: alloc::collections::VecDeque<RetainedSample>,
2048 /// Set of intra-runtime reader EntityIds that have already received the
2049 /// TransientLocal retained-sample replay, so a route recompute does not
2050 /// replay the same history twice to the same reader.
2051 intra_replayed_readers: alloc::collections::BTreeSet<EntityId>,
2052}
2053
2054/// Default same-runtime HISTORY KeepLast depth when the user has not called
2055/// [`DcpsRuntime::set_user_writer_history_depth`]. Mirrors the DDS spec default
2056/// of `depth = 1` for KEEP_LAST (DDS 1.4 §2.2.3.18 Table).
2057const DEFAULT_INTRA_HISTORY_DEPTH: usize = 1;
2058
2059/// One retained sample for the same-runtime TransientLocal replay path.
2060#[derive(Debug, Clone)]
2061struct RetainedSample {
2062 /// Instance key hash (16 byte). All-zero for NoKey topics / unknown key.
2063 key_hash: [u8; 16],
2064 /// CDR body without encapsulation header.
2065 payload: Vec<u8>,
2066 /// XCDR version tag (`0` = XCDR1, `1` = XCDR2).
2067 representation: u8,
2068 /// Writer ownership strength at write time.
2069 strength: i32,
2070 /// `Some(kind)` if this entry is a terminal lifecycle marker
2071 /// (dispose / unregister) rather than an alive sample.
2072 lifecycle: Option<zerodds_rtps::history_cache::ChangeKind>,
2073}
2074
2075/// The listener dispatch carries, alongside the `UserSample`, a
2076/// zero-copy view on the original `Arc<[u8]>` with an encap offset
2077/// (lever-E zero-copy path).
2078pub type UserSampleWithEncap = (UserSample, Option<(Arc<[u8]>, usize)>);
2079
2080/// Sample channel item: either data payload or lifecycle marker.
2081/// Lifecycle is reconstructed by the wire path as `key_hash + ChangeKind` from
2082/// the PID_STATUS_INFO header; the DataReader DCPS layer
2083/// translates that into `__push_lifecycle`.
2084#[derive(Debug, Clone)]
2085pub enum UserSample {
2086 /// Normal sample with payload (CDR-encoded application type).
2087 /// `writer_guid` is the 16-byte GUID of the emitting writer
2088 /// — needed by the subscriber for exclusive-ownership resolution
2089 /// (DDS 1.4 §2.2.3.23 / §2.2.2.5.5).
2090 Alive {
2091 /// CDR payload (without encapsulation header). Zero-copy container:
2092 /// typically holds an `Arc<[u8]>` slice into the RTPS wire datagram
2093 /// without a heap re-alloc. See `docs/specs/zerodds-zero-copy-1.0.md`.
2094 payload: crate::sample_bytes::SampleBytes,
2095 /// Writer GUID — for strongest-writer selection.
2096 writer_guid: [u8; 16],
2097 /// Writer `ownership_strength` at the time of receipt.
2098 /// `0` if the writer is not yet known via discovery
2099 /// (the reader treats this as default strength = spec-conformant
2100 /// for shared-ownership topics; for exclusive the
2101 /// reader filters the real strength against the current owner).
2102 writer_strength: i32,
2103 /// XCDR version of the `payload` — extracted from the encapsulation
2104 /// header of the wire sample (RTPS 2.5 §10.5) BEFORE the
2105 /// header was stripped: `0` = XCDR1 (CDR/PL_CDR), `1` =
2106 /// XCDR2 (CDR2/D_CDR2/PL_CDR2). The typed consumer
2107 /// needs this to decode the body with the correct alignment rule
2108 /// (XTypes 1.3 §7.4.3.4.2).
2109 representation: u8,
2110 /// Byte order of the `payload` — extracted from the encapsulation
2111 /// representation identifier's low bit (RTPS 2.5 §10.5: the `_BE`
2112 /// variants 0x0000/0x0002/0x0006/0x0008/0x000a are even, the `_LE`
2113 /// variants odd). `false` = little-endian (the canonical wire and the
2114 /// intra-runtime default), `true` = big-endian. The typed consumer
2115 /// dispatches `DdsType::decode` vs `decode_be` on this.
2116 big_endian: bool,
2117 /// Source timestamp from the writer's INFO_TS submessage (DDSI-RTPS
2118 /// §8.7.3), if any. Threaded into `SampleInfo.source_timestamp` and the
2119 /// `DESTINATION_ORDER = BY_SOURCE_TIMESTAMP` decision. `None` ⇒ the
2120 /// reader uses reception order.
2121 source_timestamp: Option<zerodds_rtps::header_extension::HeTimestamp>,
2122 },
2123 /// Lifecycle marker (dispose / unregister) — the reader sets
2124 /// InstanceState accordingly.
2125 Lifecycle {
2126 /// Key hash of the affected instance (16 byte).
2127 key_hash: [u8; 16],
2128 /// `NotAliveDisposed` / `NotAliveUnregistered` /
2129 /// `NotAliveDisposedUnregistered`.
2130 kind: zerodds_rtps::history_cache::ChangeKind,
2131 },
2132}
2133
2134/// User-reader slot. ReliableReader + topic meta + channel to the
2135/// DataReader (DCPS API side).
2136/// Listener callback for sample arrival.
2137///
2138/// Fired synchronously by `recv_user_data_loop` in the recv-thread
2139/// context as soon as an alive sample lands in the reader HistoryCache.
2140/// Eliminates the polling latency of `zerodds_reader_take()` —
2141/// the listener path typically saves 50-100 µs per side.
2142///
2143/// **Contract** (analogous to DDS spec §2.2.4.4 listener semantics):
2144/// * The callback runs on the recv thread, NOT the user thread.
2145/// * Short and non-blocking. No I/O, no locks, no
2146/// ZeroDDS API calls inside.
2147/// * `bytes` points to the CDR payload of the alive sample (without
2148/// encapsulation header). Lifetime only for the duration of the
2149/// callback; copy if needed beyond the call.
2150/// * Disposed/unregistered lifecycle events do NOT fire the listener
2151/// (only `Alive` samples) — for lifecycle tracking
2152/// keep using `zerodds_reader_take()` or add a
2153/// lifecycle-listener API.
2154///
2155/// Data-available listener. Arguments: CDR body (without encapsulation
2156/// header) and the XCDR version of the sample (`0` = XCDR1, `1` = XCDR2)
2157/// — the typed consumer needs the latter for the alignment
2158/// rule on decode (XTypes 1.3 §7.4.3.4.2).
2159pub type UserReaderListener = alloc::boxed::Box<dyn Fn(&[u8], u8, u8) + Send + Sync + 'static>;
2160
2161struct UserReaderSlot {
2162 reader: ReliableReader,
2163 topic_name: String,
2164 type_name: String,
2165 sample_tx: mpsc::Sender<UserSample>,
2166 /// Spec §3 zerodds-async-1.0: async waker slot. Registered by the
2167 /// async reader; on `sample_tx.send` we call
2168 /// `waker.wake()`. `None` if no async reader is active.
2169 async_waker: alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>,
2170 /// Listener callback for alive samples.
2171 /// Fired synchronously by `recv_user_data_loop`. `None` =
2172 /// no listener registered (the user polls via
2173 /// `zerodds_reader_take()`). Arc, so the recv thread can
2174 /// execute the callback cloned without another lock (minimize lock
2175 /// hold time).
2176 listener: Option<alloc::sync::Arc<UserReaderListener>>,
2177 durability: zerodds_qos::DurabilityKind,
2178 /// Deadline period in nanoseconds (0 == INFINITE).
2179 deadline_nanos: u64,
2180 /// Time of the last received sample relative to runtime start.
2181 last_sample_received: Option<Duration>,
2182 /// Counter for missed deadline expectations (Spec §2.2.4.2.11).
2183 requested_deadline_missed_count: u64,
2184 /// Per-policy counter for requested_incompatible_qos. Spec
2185 /// §2.2.4.2.6.5 — reader side. Incremented on
2186 /// `wire_reader_to_remote_writer` reject.
2187 requested_incompatible_qos: crate::status::RequestedIncompatibleQosStatus,
2188 /// Sample-lost counter (Spec §2.2.4.2.6.2). Incremented
2189 /// by `record_sample_lost`.
2190 sample_lost_count: u64,
2191 /// Sample-rejected counter (Spec §2.2.4.2.6.3). Incremented
2192 /// by `record_sample_rejected`.
2193 sample_rejected: crate::status::SampleRejectedStatus,
2194 /// Monotonically increasing count of alive samples delivered to the
2195 /// user. Serves as a non-consuming data-availability detector for
2196 /// `on_data_available` (DDS 1.4 §2.2.4.2.6.1) — unlike
2197 /// `last_sample_received`, this counter is only bumped on real sample
2198 /// delivery, never by the deadline path. Read via
2199 /// [`DcpsRuntime::user_reader_samples_delivered`].
2200 samples_delivered_count: u64,
2201 /// Reader-side requested liveliness lease (0 == INFINITE).
2202 liveliness_lease_nanos: u64,
2203 /// Reader-side requested liveliness kind.
2204 liveliness_kind: zerodds_qos::LivelinessKind,
2205 /// Counter: how often the writer was marked "alive"
2206 /// (Spec §2.2.4.2.14 alive_count).
2207 liveliness_alive_count: u64,
2208 /// Counter: how often it was marked "not_alive" (lease expired).
2209 liveliness_not_alive_count: u64,
2210 /// Current "alive/not-alive" state from the reader's view.
2211 liveliness_alive: bool,
2212 /// QR-cluster (e): set of writer GUIDs the reader currently considers alive
2213 /// via an AUTOMATIC-liveliness same-runtime match. Used to bump
2214 /// `liveliness_alive_count` exactly once per writer-alive transition on the
2215 /// intra-runtime path (the wire DATA path tracks this via reader proxies).
2216 liveliness_alive_writers: alloc::collections::BTreeSet<[u8; 16]>,
2217 /// Ownership.
2218 ownership: zerodds_qos::OwnershipKind,
2219 /// Partition.
2220 partition: Vec<String>,
2221 /// Per-writer strength cache for exclusive-ownership resolution
2222 /// (DDS 1.4 §2.2.3.23). Filled by `wire_reader_to_remote_writer`
2223 /// from each `PublicationBuiltinTopicData.ownership_strength`;
2224 /// `delivered_to_user_sample` looks it up here to pack the
2225 /// strength into `UserSample::Alive`.
2226 writer_strengths: alloc::collections::BTreeMap<[u8; 16], i32>,
2227 /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the reader type
2228 /// (from `T::TYPE_IDENTIFIER` in `UserReaderConfig`). Default
2229 /// `TypeIdentifier::None` signals "no TypeIdentifier" —
2230 /// the match falls back to a pure `type_name` comparison
2231 /// (DDS 1.4 §2.2.3 default path).
2232 type_identifier: zerodds_types::TypeIdentifier,
2233 /// XTypes 1.3 §7.6.3.7 — TCE policy controlling the strictness
2234 /// of the XTypes match path.
2235 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2236 /// A2 — TIME_BASED_FILTER `minimum_separation` (DDS 1.4 §2.2.3.12), in
2237 /// nanoseconds, for the runtime/C-FFI delivery path. `0` (default) = off.
2238 /// Set via [`DcpsRuntime::set_user_reader_time_based_filter`] (the
2239 /// `rmw_zerodds` / C-FFI path; the typed entity reader enforces TBF on its
2240 /// own QoS). See [`UserReaderSlot::tbf_should_deliver`].
2241 tbf_min_separation_nanos: u128,
2242 /// Per-instance last-delivered timestamp (nanoseconds since runtime start),
2243 /// keyed by the sample KeyHash (keyless types share the all-zero key). Only
2244 /// populated when `tbf_min_separation_nanos > 0`.
2245 tbf_last_delivered: alloc::collections::BTreeMap<[u8; 16], u128>,
2246}
2247
2248impl UserReaderSlot {
2249 /// A2 — TIME_BASED_FILTER gate (DDS 1.4 §2.2.3.12) for the runtime delivery
2250 /// path: returns `true` if a sample of the given instance may be delivered,
2251 /// i.e. at least `minimum_separation` has elapsed since the last delivered
2252 /// sample of that instance. The first sample of an instance always passes.
2253 /// `key_hash = None` (keyless type) collapses to a single instance. A
2254 /// `minimum_separation` of 0 disables the filter (always `true`).
2255 fn tbf_should_deliver(&mut self, key_hash: Option<[u8; 16]>, now_nanos: u128) -> bool {
2256 if self.tbf_min_separation_nanos == 0 {
2257 return true;
2258 }
2259 let inst = key_hash.unwrap_or([0u8; 16]);
2260 match self.tbf_last_delivered.get(&inst) {
2261 Some(&last) if now_nanos.saturating_sub(last) < self.tbf_min_separation_nanos => false,
2262 _ => {
2263 self.tbf_last_delivered.insert(inst, now_nanos);
2264 true
2265 }
2266 }
2267 }
2268}
2269
2270/// Helper struct for announcing a local publication/subscription
2271/// as SEDP BuiltinTopicData. The caller creates it once per
2272/// writer/reader registration and passes it to SedpStack.
2273/// QoS config for registering a user writer with the runtime.
2274/// Bundles all policies that go on the wire via SEDP plus the local
2275/// Per-endpoint discovery info for ROS 2 endpoint-info-by-topic introspection
2276/// (`rmw_get_publishers_info_by_topic` / `rmw_get_subscriptions_info_by_topic`,
2277/// the data behind `ros2 topic info -v`). Covers local user endpoints plus
2278/// remote SEDP-discovered ones. QoS is best-effort from what discovery carries
2279/// (history/depth are not on the wire, so the consumer fills rmw defaults).
2280#[derive(Debug, Clone)]
2281pub struct DiscoveredEndpointInfo {
2282 /// DDS topic name (raw, un-demangled).
2283 pub topic_name: String,
2284 /// IDL type name (raw).
2285 pub type_name: String,
2286 /// 16-byte endpoint GUID: 12-byte participant prefix + 4-byte entity id.
2287 /// Bytes 0..12 identify the owning participant (for node-name lookup).
2288 pub endpoint_guid: [u8; 16],
2289 /// RELIABLE (`true`) vs BEST_EFFORT (`false`).
2290 pub reliable: bool,
2291 /// TRANSIENT_LOCAL or stronger (`true`) vs VOLATILE (`false`).
2292 pub transient_local: bool,
2293 /// Deadline period in whole seconds (0 == INFINITE).
2294 pub deadline_seconds: i32,
2295 /// Lifespan in whole seconds (0 == INFINITE; always 0 for subscriptions).
2296 pub lifespan_seconds: i32,
2297 /// Liveliness lease in whole seconds (0 == INFINITE).
2298 pub liveliness_lease_seconds: i32,
2299}
2300
2301/// Packs an RTPS [`Guid`] into the 16-byte wire form (prefix ++ entity id).
2302fn guid_to_16(g: Guid) -> [u8; 16] {
2303 let mut b = [0u8; 16];
2304 b[..12].copy_from_slice(&g.prefix.to_bytes());
2305 b[12..].copy_from_slice(&g.entity_id.to_bytes());
2306 b
2307}
2308
2309/// monitoring. Avoids 10+-argument functions.
2310#[derive(Debug, Clone)]
2311pub struct UserWriterConfig {
2312 /// Topic name (DDS topic).
2313 pub topic_name: String,
2314 /// IDL type name.
2315 pub type_name: String,
2316 /// `true` = RELIABLE, `false` = BEST_EFFORT.
2317 pub reliable: bool,
2318 /// Durability.
2319 pub durability: zerodds_qos::DurabilityKind,
2320 /// Deadline period (offered).
2321 pub deadline: zerodds_qos::DeadlineQosPolicy,
2322 /// Lifespan duration (writer-only).
2323 pub lifespan: zerodds_qos::LifespanQosPolicy,
2324 /// Liveliness (offered).
2325 pub liveliness: zerodds_qos::LivelinessQosPolicy,
2326 /// Ownership mode (Shared / Exclusive).
2327 pub ownership: zerodds_qos::OwnershipKind,
2328 /// Strength for Exclusive (ignored for Shared).
2329 pub ownership_strength: i32,
2330 /// Partition list. Empty == default partition (`""`).
2331 pub partition: Vec<String>,
2332 /// UserData QoS (Spec §2.2.3.1) — opaque `sequence<octet>`, propagated
2333 /// via discovery.
2334 pub user_data: Vec<u8>,
2335 /// TopicData QoS (Spec §2.2.3.3).
2336 pub topic_data: Vec<u8>,
2337 /// GroupData QoS (Spec §2.2.3.2).
2338 pub group_data: Vec<u8>,
2339 /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up). Default
2340 /// `TypeIdentifier::None` for the `T::TYPE_IDENTIFIER` default.
2341 pub type_identifier: zerodds_types::TypeIdentifier,
2342
2343 /// D.5g — per-writer override of the DataRepresentation offer list.
2344 /// `None` = use `RuntimeConfig::data_representation_offer`.
2345 /// `Some(vec)` = overridden per writer (e.g. `[XCDR2]` for
2346 /// a modern-only pub).
2347 pub data_representation_offer: Option<Vec<i16>>,
2348}
2349
2350/// QoS config for registering a user reader.
2351#[derive(Debug, Clone)]
2352pub struct UserReaderConfig {
2353 /// Topic name.
2354 pub topic_name: String,
2355 /// IDL type name.
2356 pub type_name: String,
2357 /// `true` = RELIABLE, `false` = BEST_EFFORT.
2358 pub reliable: bool,
2359 /// Durability (requested).
2360 pub durability: zerodds_qos::DurabilityKind,
2361 /// Deadline (requested).
2362 pub deadline: zerodds_qos::DeadlineQosPolicy,
2363 /// Liveliness (requested).
2364 pub liveliness: zerodds_qos::LivelinessQosPolicy,
2365 /// Ownership.
2366 pub ownership: zerodds_qos::OwnershipKind,
2367 /// Partition.
2368 pub partition: Vec<String>,
2369 /// UserData QoS (Spec §2.2.3.1).
2370 pub user_data: Vec<u8>,
2371 /// TopicData QoS (Spec §2.2.3.3).
2372 pub topic_data: Vec<u8>,
2373 /// GroupData QoS (Spec §2.2.3.2).
2374 pub group_data: Vec<u8>,
2375 /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up).
2376 pub type_identifier: zerodds_types::TypeIdentifier,
2377 /// TypeConsistencyEnforcement (XTypes §7.6.3.7) — controls how strictly
2378 /// the reader match checks XTypes compatibility.
2379 pub type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2380
2381 /// D.5g — per-reader override of the DataRepresentation accept list.
2382 /// `None` = use `RuntimeConfig::data_representation_offer`.
2383 /// `Some(vec)` = overridden per reader (e.g. `[XCDR1]` for
2384 /// a reader that accepts only legacy XCDR1 wire).
2385 pub data_representation_offer: Option<Vec<i16>>,
2386}
2387
2388fn build_publication_data(
2389 owner_prefix: GuidPrefix,
2390 writer_eid: EntityId,
2391 cfg: &UserWriterConfig,
2392 runtime_offer: &[i16],
2393 user_locator: Locator,
2394) -> zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2395 use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2396 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2397 key: Guid::new(owner_prefix, writer_eid),
2398 participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2399 topic_name: cfg.topic_name.clone(),
2400 type_name: cfg.type_name.clone(),
2401 durability: cfg.durability,
2402 reliability: ReliabilityQosPolicy {
2403 kind: if cfg.reliable {
2404 ReliabilityKind::Reliable
2405 } else {
2406 ReliabilityKind::BestEffort
2407 },
2408 max_blocking_time: QosDuration::from_millis(100_i32),
2409 },
2410 ownership: cfg.ownership,
2411 ownership_strength: cfg.ownership_strength,
2412 liveliness: cfg.liveliness,
2413 deadline: cfg.deadline,
2414 lifespan: cfg.lifespan,
2415 partition: cfg.partition.clone(),
2416 user_data: cfg.user_data.clone(),
2417 topic_data: cfg.topic_data.clone(),
2418 group_data: cfg.group_data.clone(),
2419 type_information: None,
2420 // D.5g — PID_DATA_REPRESENTATION (XTypes 1.3 §7.6.3.1.1, RTPS 2.5
2421 // PID 0x0073). Per-Writer-Override (cfg.data_representation_offer)
2422 // overrides the RuntimeConfig default.
2423 data_representation: cfg
2424 .data_representation_offer
2425 .clone()
2426 .unwrap_or_else(|| runtime_offer.to_vec()),
2427 // Security: the PolicyEngine fills this later. Default
2428 // None = legacy behavior (no EndpointSecurityInfo PID).
2429 security_info: None,
2430 // .B — RPC discovery PIDs. Default None: no RPC endpoint;
2431 // the RpcEndpoint builder fills these fields.
2432 service_instance_name: None,
2433 related_entity_guid: None,
2434 topic_aliases: None,
2435 // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2436 type_identifier: cfg.type_identifier.clone(),
2437 // DDSI-RTPS 2.5 §8.5.3.3: endpoint locator. All user endpoints
2438 // share the one `user_unicast` socket — hence the
2439 // endpoint locator equals the resolved participant locator.
2440 unicast_locators: alloc::vec![user_locator],
2441 multicast_locators: Vec::new(),
2442 }
2443}
2444
2445/// The `DataRepresentation` set a **DataReader** announces (PID_DATA_REPRESENTATION
2446/// in its SEDP subscription). Per OMG XTypes 1.3 §7.6.2, a reader with the
2447/// default (empty) policy accepts **both** XCDR1 and XCDR2 — and ZeroDDS decodes
2448/// both (the read path dispatches on the per-sample encapsulation id). So the
2449/// reader advertises every representation it can decode, not just the writer's
2450/// preferred one.
2451///
2452/// This matters cross-vendor: CycloneDDS (and legacy RTI / OpenDDS < 3.16)
2453/// default their *writers* to **XCDR1** for `@final` types (non-XTypes backward
2454/// compat). A reader that only announces XCDR2 makes those writers fail the
2455/// `DataRepresentation` RxO check — the writer never forms a connection, and the
2456/// samples are dropped before any are sent. We therefore start from the
2457/// configured offer (which fixes the *preferred* order) and ensure both XCDR2
2458/// and XCDR1 are present. A WRITER keeps the narrow offer (`build_publication_data`),
2459/// because the generated encoder emits one representation.
2460fn reader_accept_repr(configured_offer: &[i16]) -> Vec<i16> {
2461 use zerodds_rtps::publication_data::data_representation as dr;
2462 let mut out: Vec<i16> = configured_offer.to_vec();
2463 for id in [dr::XCDR2, dr::XCDR] {
2464 if !out.contains(&id) {
2465 out.push(id);
2466 }
2467 }
2468 out
2469}
2470
2471fn build_subscription_data(
2472 owner_prefix: GuidPrefix,
2473 reader_eid: EntityId,
2474 cfg: &UserReaderConfig,
2475 runtime_offer: &[i16],
2476 user_locator: Locator,
2477) -> zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2478 use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2479 zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2480 key: Guid::new(owner_prefix, reader_eid),
2481 participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2482 topic_name: cfg.topic_name.clone(),
2483 type_name: cfg.type_name.clone(),
2484 durability: cfg.durability,
2485 reliability: ReliabilityQosPolicy {
2486 kind: if cfg.reliable {
2487 ReliabilityKind::Reliable
2488 } else {
2489 ReliabilityKind::BestEffort
2490 },
2491 max_blocking_time: QosDuration::from_millis(100_i32),
2492 },
2493 ownership: cfg.ownership,
2494 liveliness: cfg.liveliness,
2495 deadline: cfg.deadline,
2496 partition: cfg.partition.clone(),
2497 user_data: cfg.user_data.clone(),
2498 topic_data: cfg.topic_data.clone(),
2499 group_data: cfg.group_data.clone(),
2500 type_information: None,
2501 // D.5g — PID_DATA_REPRESENTATION (see build_publication_data).
2502 // A per-reader override overrides the RuntimeConfig default.
2503 data_representation: cfg
2504 .data_representation_offer
2505 .clone()
2506 .unwrap_or_else(|| runtime_offer.to_vec()),
2507 content_filter: None,
2508 security_info: None,
2509 service_instance_name: None,
2510 related_entity_guid: None,
2511 topic_aliases: None,
2512 // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2513 type_identifier: cfg.type_identifier.clone(),
2514 // DDSI-RTPS 2.5 §8.5.3.2: endpoint locator (see
2515 // build_publication_data).
2516 unicast_locators: alloc::vec![user_locator],
2517 multicast_locators: Vec::new(),
2518 }
2519}
2520
2521/// The runtime of a `DomainParticipant`. Hosts all background
2522/// threads and UDP sockets.
2523pub struct DcpsRuntime {
2524 /// Participant GUID prefix (12-byte identifier, random per instance).
2525 pub guid_prefix: GuidPrefix,
2526 /// Domain id.
2527 pub domain_id: i32,
2528 /// SPDP multicast receiver socket.
2529 pub spdp_multicast_rx: Arc<UdpTransport>,
2530 /// SPDP unicast socket (for bidirectional SPDP, B2).
2531 pub spdp_unicast: Arc<UdpTransport>,
2532 /// User-data unicast transport (default user unicast, where peers
2533 /// send matched samples). Trait object: can be UDP/v4 or /v6,
2534 /// and in phase C additionally TCP or SHM (env var
2535 /// `ZERODDS_USER_TRANSPORT`). Discovery (SPDP/SEDP) stays UDP-only.
2536 pub user_unicast: Arc<dyn Transport + Send + Sync>,
2537 /// Resolved user-unicast locator (routable interface address,
2538 /// not `0.0.0.0`). Written as `PID_UNICAST_LOCATOR` into EVERY
2539 /// SEDP pub/sub announce (DDSI-RTPS 2.5 §8.5.3.2/3)
2540 /// and as the participant `DEFAULT_UNICAST_LOCATOR` in SPDP. Precomputed
2541 /// via `announce_locator`, so the endpoint and participant locators
2542 /// are guaranteed identical.
2543 pub user_announce_locator: Locator,
2544 /// Sender socket for the SPDP multicast announce (separate UdpSocket
2545 /// without SO_REUSE/SO_BIND_IP_MULTICAST, so send_to routes cleanly).
2546 spdp_mc_tx: Arc<UdpTransport>,
2547 /// SPDP beacon (sends periodic announces).
2548 spdp_beacon: Mutex<SpdpBeacon>,
2549 /// Own participant data (SPDP self-view). Handed by the in-process
2550 /// discovery fastpath as a `DiscoveredParticipant` to same-process
2551 /// peers (see [`crate::inproc`]).
2552 participant_data: ParticipantBuiltinTopicData,
2553 /// Stash of all locally announced publications/subscriptions —
2554 /// so a peer runtime starting later in the same process
2555 /// can pull our endpoints via `inproc_snapshot`
2556 /// (pull-on-creation of the in-process discovery fastpath).
2557 /// Append-only; a future patch for endpoint deletion would
2558 /// remove by GUID here.
2559 announced_pubs: Mutex<Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>>,
2560 announced_subs: Mutex<Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>>,
2561 /// SPDP reader (parses incoming beacons).
2562 spdp_reader: SpdpReader,
2563 /// Discovered remote participants (prefix → data).
2564 discovered: Arc<Mutex<DiscoveredParticipantsCache>>,
2565 /// A1 discovery-server relay: cache of the last raw SPDP datagram per
2566 /// discovered participant prefix. Only populated in `discovery_server` mode;
2567 /// used to forward a newly-joined client the SPDP of every already-known
2568 /// client (and vice versa). Empty otherwise.
2569 spdp_relay_cache: Mutex<alloc::collections::BTreeMap<GuidPrefix, Vec<u8>>>,
2570 /// SEDP stack for publication/subscription announce + discovery.
2571 pub sedp: Arc<Mutex<SedpStack>>,
2572 /// TypeLookup-Service Builtin-Endpoint-GUIDs (XTypes 1.3 §7.6.3.3.4).
2573 pub type_lookup_endpoints: TypeLookupEndpoints,
2574 /// TypeLookup server (server-side handler over the local
2575 /// TypeRegistry).
2576 pub type_lookup_server: Arc<Mutex<TypeLookupServer>>,
2577 /// TypeLookup client (client-side correlation table for outstanding
2578 /// requests).
2579 pub type_lookup_client: Arc<Mutex<TypeLookupClient>>,
2580 /// Monotonically increasing sequence number of the TL_SVC_REPLY_WRITER. Reply DATA
2581 /// carry their OWN writer_sn (instead of echoing the request SN) — the
2582 /// correlation runs via PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2),
2583 /// so a reliable cross-vendor reply reader sees no SN jumps.
2584 tl_reply_sn: core::sync::atomic::AtomicU64,
2585 /// Security builtin endpoint stack
2586 /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
2587 /// MessageSecure`). `None` as long as no security plugin is active
2588 /// — the hot path then skips any security-builtin
2589 /// demux. `Some` is set via [`DcpsRuntime::enable_security_builtins`]
2590 /// as soon as the factory has registered a plugin.
2591 pub security_builtin: Mutex<Option<Arc<Mutex<SecurityBuiltinStack>>>>,
2592 /// Monotonic "start time" — for SEDP tick clocks.
2593 start_instant: Instant,
2594 /// Local user-writer registry (EntityId → writer state).
2595 user_writers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserWriterSlot>>>>>,
2596 /// ADR-0006 side map: per user writer an optional ShmLocator bytes
2597 /// value (PID_SHM_LOCATOR in the SEDP sample). `None` = no
2598 /// same-host backend attached. The wire encoder consults
2599 /// this map on the SEDP push.
2600 shm_locators: Arc<RwLock<BTreeMap<EntityId, Vec<u8>>>>,
2601 /// Wave 4 (Spec `zerodds-zero-copy-1.0` §6): tracker for
2602 /// same-host (writer, reader) pairs. The SEDP match hook registers
2603 /// here every pair whose remote prefix carries the same host-id prefix
2604 /// as the local participant. The hot-path send consults
2605 /// the tracker and routes over SHM instead of UDP in the `Bound` state.
2606 pub same_host: Arc<crate::same_host::SameHostTracker>,
2607 /// Local user-reader registry (EntityId → reader state).
2608 user_readers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserReaderSlot>>>>>,
2609 /// Cross-vendor step 6b: peers to whom we have already sent per-endpoint
2610 /// crypto tokens (datawriter/datareader). Prevents spam on the
2611 /// repeated receipt of cyclone's tokens; sending happens only once the
2612 /// user endpoints exist (the bench creates them after handshake start).
2613 #[cfg(feature = "security")]
2614 /// Already-sent per-endpoint crypto tokens, per dedup key
2615 /// (source_endpoint ++ destination_endpoint, see `endpoint_token_key`).
2616 /// Per-token instead of per-peer, so late-matched user endpoints still
2617 /// get their tokens (#29).
2618 endpoint_tokens_sent: Arc<RwLock<alloc::collections::BTreeSet<[u8; 32]>>>,
2619 /// Peers (prefix) to whom our SEDP endpoint records have already been
2620 /// re-announced after a completed crypto-token exchange. Under rtps_/discovery_
2621 /// protection the initial SEDP burst is discarded by the peer (no key), until
2622 /// the participant crypto token arrives via Volatile; a one-time
2623 /// re-announce from that moment (the peer can now decode) brings the
2624 /// dropped SEDP up (OpenDDS flow; cyclone/FastDDS converge anyway).
2625 #[cfg(feature = "security")]
2626 sedp_reannounced: Arc<RwLock<alloc::collections::BTreeSet<[u8; 12]>>>,
2627 /// Per-endpoint crypto (DDS-Security §9.5.3.3): per local writer/reader
2628 /// EntityId its own crypto slot handle (its own key material, not the
2629 /// participant key). Used for the per-endpoint token (prepare_endpoint_
2630 /// crypto_tokens) AND the per-endpoint encode (protect_user_datagram)
2631 /// — the same key on both sides. Get-or-register lazily via
2632 /// `local_endpoint_crypto_handle`.
2633 #[cfg(feature = "security")]
2634 endpoint_crypto:
2635 Arc<RwLock<alloc::collections::BTreeMap<EntityId, zerodds_security::crypto::CryptoHandle>>>,
2636 /// Same-runtime writer→reader routes: per local writer the list
2637 /// of local readers subscribed to the same topic+type.
2638 /// Rebuilt in `recompute_intra_runtime_routes` on every
2639 /// register/unregister. Looked up in the write hot path,
2640 /// to push samples directly into the reader slot's `sample_tx`
2641 /// (intra-process loopback without an RTPS roundtrip, in parallel to the
2642 /// inproc peer path that only serves cross-runtime peers).
2643 intra_runtime_routes: Arc<RwLock<BTreeMap<EntityId, Vec<EntityId>>>>,
2644 /// Entity key counter (3 byte, incrementing). User writers use
2645 /// `0xC2` (with-key, user), user readers `0xC7`.
2646 entity_counter: AtomicU32,
2647 /// Configuration (cloned from RuntimeConfig).
2648 pub config: RuntimeConfig,
2649 /// Per-interface outbound socket pool. `None`
2650 /// when `config.interface_bindings` is empty — then
2651 /// `user_unicast` stays the only outbound socket (v1.4 path).
2652 #[cfg(feature = "security")]
2653 outbound_pool: Option<Arc<OutboundSocketPool>>,
2654 /// Writer-Liveliness-Protocol endpoint (RTPS 2.5 §8.4.13).
2655 /// Sends periodic `ParticipantMessageData` heartbeats and
2656 /// tracks last-seen per remote participant.
2657 pub wlp: Arc<Mutex<crate::wlp::WlpEndpoint>>,
2658 /// Builtin-topic reader sinks (DDS 1.4 §2.2.5). Set by the
2659 /// `DomainParticipant` constructor via `attach_builtin_sinks`;
2660 /// before that this is `None` and the discovery hot path
2661 /// drops samples silently (e.g. when the runtime is
2662 /// started directly for internal tests, without a participant).
2663 builtin_sinks: Mutex<Option<crate::builtin_subscriber::BuiltinSinks>>,
2664 /// Ignore filter (DDS 1.4 §2.2.2.2.1.14-17). Set by the
2665 /// `DomainParticipant` constructor via `attach_ignore_filter`.
2666 /// `None` means: no participant hook → no
2667 /// filtering.
2668 ignore_filter: Mutex<Option<crate::participant::IgnoreFilter>>,
2669 /// Stop flag for all worker threads (recv loops + tick loop).
2670 stop: Arc<AtomicBool>,
2671 /// Monotonic count of completed tick iterations. Incremented once per
2672 /// [`run_tick_iteration`], regardless of whether the tick is driven by the
2673 /// internal `zdds-tick` thread or an external executor (zerodds-async-1.0
2674 /// §4 `spawn_in_tokio`). Diagnostic: a stalled count means the tick loop
2675 /// stopped advancing. Read via [`DcpsRuntime::tick_count`].
2676 tick_seq: AtomicU64,
2677 /// Total SPDP announces emitted (multicast + unicast fan-out count as one).
2678 /// Diagnostic for the C3 initial-announcement burst — a fresh, peer-less
2679 /// participant should advance this fast initially. Read via
2680 /// [`DcpsRuntime::spdp_announce_count`].
2681 spdp_announce_seq: AtomicU64,
2682 /// Inconsistent-topic counter (DDS 1.4 §2.2.4.2.4). Incremented when
2683 /// matching discovers a remote endpoint carrying the same `topic_name`
2684 /// but a differing `type_name` in the SEDP cache. Read via
2685 /// [`DcpsRuntime::inconsistent_topic_count`].
2686 inconsistent_topic_seq: AtomicU64,
2687 /// D.5e Phase 3 — wake handle for the event-driven scheduler tick. `Some`
2688 /// only when started with `scheduler_tick`. Recv loops + the write path call
2689 /// [`DcpsRuntime::raise_tick_wake`] to wake the worker immediately on new
2690 /// work (so HEARTBEAT/ACKNACK/HB processing does not wait for a deadline).
2691 tick_wake: Mutex<Option<crate::scheduler::SchedulerHandle<TickEvent>>>,
2692 /// Coalesces wake raises: many incoming datagrams collapse into one wake.
2693 tick_wake_pending: AtomicBool,
2694 /// Worker thread JoinHandles. Per-socket recv threads + tick thread,
2695 /// all terminated together via `stop` (Sprint D.5b — previously
2696 /// a single single-threaded `event_loop`).
2697 handles: Mutex<Vec<JoinHandle<()>>>,
2698 /// Match-event notifier (D.5e Phase-1 quick win). Notified by the
2699 /// SEDP match path after `add_reader_proxy` / `add_writer_proxy`;
2700 /// `wait_for_matched_*` parks on it instead of polling every 20 ms.
2701 /// The mutex content is only a lock anchor for the Condvar API; there is
2702 /// no state protected by it (the count is read independently
2703 /// via `user_*_matched_count`).
2704 match_event: Arc<(Mutex<()>, Condvar)>,
2705 /// Acknowledgments event notifier. Notified when a writer
2706 /// receives an ACKNACK that advances its acked-base.
2707 /// `wait_for_acknowledgments` parks on it instead of polling every 50 ms.
2708 ack_event: Arc<(Mutex<()>, Condvar)>,
2709}
2710
2711impl core::fmt::Debug for DcpsRuntime {
2712 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2713 f.debug_struct("DcpsRuntime")
2714 .field("domain_id", &self.domain_id)
2715 .field("guid_prefix", &self.guid_prefix)
2716 .field("spdp_group", &self.config.spdp_multicast_group)
2717 .finish_non_exhaustive()
2718 }
2719}
2720
2721/// Type alias: Arc-shared slot handles from the per-slot mutex
2722/// architecture.
2723type WriterSlotArc = Arc<Mutex<UserWriterSlot>>;
2724type ReaderSlotArc = Arc<Mutex<UserReaderSlot>>;
2725
2726impl DcpsRuntime {
2727 /// The 16-byte RTPS GUID of a local writer with EntityId `eid` (this
2728 /// participant's GUID prefix ++ the entity id). Used by the cross-vendor
2729 /// iceoryx-cyclone bridge to stamp the PSMX chunk with the writer's real
2730 /// GUID so a peer that discovered the writer over RTPS SEDP associates the
2731 /// shared-memory sample with it.
2732 #[must_use]
2733 pub fn writer_guid(&self, eid: EntityId) -> [u8; 16] {
2734 Guid::new(self.guid_prefix, eid).to_bytes()
2735 }
2736
2737 // ========================================================================
2738 // --- Per-Slot-Mutex-Helpers
2739 //
2740 // The `user_writers`/`user_readers` registry is `RwLock<BTreeMap<EntityId,
2741 // Arc<Mutex<Slot>>>>`. Hot-path accesses take the read lock briefly, clone
2742 // the slot Arc and release the read lock before taking the per-slot mutex.
2743 // Parallel writes to **different** slots thereby run
2744 // without global contention.
2745 //
2746 // Slot creation/deletion takes the write lock; that is rare and
2747 // amortizes out.
2748 // ========================================================================
2749
2750 /// Returns the slot Arc for a user writer, if present.
2751 /// Hot-path form: a single read lock + Arc clone, no
2752 /// per-slot mutex. The caller takes the mutex itself.
2753 fn writer_slot(&self, eid: EntityId) -> Option<WriterSlotArc> {
2754 self.user_writers
2755 .read()
2756 .ok()
2757 .and_then(|w| w.get(&eid).cloned())
2758 }
2759
2760 /// Returns the slot Arc for a user reader, if present.
2761 fn reader_slot(&self, eid: EntityId) -> Option<ReaderSlotArc> {
2762 self.user_readers
2763 .read()
2764 .ok()
2765 .and_then(|r| r.get(&eid).cloned())
2766 }
2767
2768 /// Snapshot of all writer slots as `Vec<(EntityId, Arc)>`. Allows
2769 /// iteration without holding the registry read lock — e.g. for
2770 /// the heartbeat tick or liveliness sweep, where we potentially take every
2771 /// slot's mutex.
2772 fn writer_slots_snapshot(&self) -> Vec<(EntityId, WriterSlotArc)> {
2773 match self.user_writers.read() {
2774 Ok(w) => w.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2775 Err(_) => Vec::new(),
2776 }
2777 }
2778
2779 /// Snapshot of all reader slots — symmetric to writer_slots_snapshot.
2780 fn reader_slots_snapshot(&self) -> Vec<(EntityId, ReaderSlotArc)> {
2781 match self.user_readers.read() {
2782 Ok(r) => r.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2783 Err(_) => Vec::new(),
2784 }
2785 }
2786
2787 /// Returns the list of EntityIds of all registered writers.
2788 /// Very lightweight — no slot-Arc clone, just EntityIds.
2789 fn writer_eids(&self) -> Vec<EntityId> {
2790 match self.user_writers.read() {
2791 Ok(w) => w.keys().copied().collect(),
2792 Err(_) => Vec::new(),
2793 }
2794 }
2795
2796 /// Returns the list of EntityIds of all registered readers.
2797 fn reader_eids(&self) -> Vec<EntityId> {
2798 match self.user_readers.read() {
2799 Ok(r) => r.keys().copied().collect(),
2800 Err(_) => Vec::new(),
2801 }
2802 }
2803
2804 /// Starts a new runtime for a participant.
2805 ///
2806 /// # Errors
2807 /// `TransportError` if one of the 3 UDP sockets fails to bind
2808 /// (e.g. a port collision on the SPDP multicast port in another
2809 /// SO_REUSE-less DDS instance).
2810 pub fn start(
2811 domain_id: i32,
2812 guid_prefix: GuidPrefix,
2813 mut config: RuntimeConfig,
2814 ) -> Result<Arc<Self>> {
2815 // C1 multicast-free discovery: merge the domain-aware env `ZERODDS_PEERS`
2816 // into the (programmatic) `config.initial_peers`. Default
2817 // is both empty → pure multicast behavior.
2818 config
2819 .initial_peers
2820 .extend(parse_initial_peers_env(domain_id as u32));
2821 // SPDP multicast receiver on the spec port.
2822 // u32 → u16 enforcing, the spec port is always < 65536.
2823 let spdp_port = u16::try_from(spdp_multicast_port(domain_id as u32)).map_err(|_| {
2824 DdsError::BadParameter {
2825 what: "domain_id too large for SPDP port mapping",
2826 }
2827 })?;
2828 let spdp_mc = UdpTransport::bind_multicast_v4(
2829 config.spdp_multicast_group,
2830 spdp_port,
2831 config.multicast_interface,
2832 )
2833 .map_err(|_| DdsError::TransportError {
2834 label: "spdp multicast bind",
2835 })?
2836 // Sprint D.5b: recv sockets have their own thread that
2837 // blocks waiting for data. Timeout 1 s = stop-flag polling
2838 // granularity at shutdown, NOT the tick rhythm.
2839 .with_timeout(Some(Duration::from_secs(1)))
2840 .map_err(|_| DdsError::TransportError {
2841 label: "spdp multicast set_timeout",
2842 })?;
2843
2844 // SPDP unicast: bind to the **well-known** RTPS port
2845 // (7400+250*domain+10+2*pid, Spec §9.6.1.4.1), so a
2846 // configured unicast initial peer can reach this participant
2847 // WITHOUT prior multicast (C1 multicast-free
2848 // discovery). Participant index 0,1,2,… until a free port
2849 // is found (multiple participants per host, also alongside
2850 // Cyclone/FastDDS). Fallback ephemeral if all well-known
2851 // ports are taken (then multicast discovery only).
2852 // Interface pinning (ZERODDS_INTERFACE): UNSPECIFIED = auto. If
2853 // set, ALL IP sockets bind (SPDP-uc, SPDP-mc-tx, user UDP/TCP)
2854 // to this IP → announce + egress + receive on exactly this
2855 // interface (multi-homed robustness, cf. Cyclone `NetworkInterface`).
2856 let pinned = config.multicast_interface;
2857 let (spdp_uc_raw, _spdp_participant_id) = {
2858 let mut bound = None;
2859 for pid in 0u32..120 {
2860 let Ok(port) = u16::try_from(spdp_unicast_port(domain_id as u32, pid)) else {
2861 break;
2862 };
2863 if let Ok(sock) = UdpTransport::bind_v4(pinned, port) {
2864 bound = Some((sock, pid));
2865 break;
2866 }
2867 }
2868 match bound {
2869 Some(b) => b,
2870 None => (
2871 UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2872 label: "spdp unicast bind",
2873 })?,
2874 u32::MAX,
2875 ),
2876 }
2877 };
2878 let spdp_uc = spdp_uc_raw
2879 .with_timeout(Some(Duration::from_secs(1)))
2880 .map_err(|_| DdsError::TransportError {
2881 label: "spdp unicast set_timeout",
2882 })?;
2883
2884 // User-data unicast (ephemeral port). Transport choice primarily via
2885 // `RuntimeConfig::user_transport`, fallback to the env var
2886 // `ZERODDS_USER_TRANSPORT` (bench binaries), otherwise UDPv4.
2887 // SPDP multicast stays UDPv4 — the DDSI-RTPS spec mandates
2888 // 239.255.0.1 for cross-vendor discovery; v6-only hosts
2889 // cannot discover cross-vendor (its own sprint).
2890 let (user_uc, tcp_accept_handle): (Arc<dyn Transport + Send + Sync>, _) =
2891 if !config.user_transports.is_empty() {
2892 // Multi-transport: build each kind and layer them. Preference =
2893 // config order (first match by destination locator kind wins).
2894 let mut legs: alloc::vec::Vec<Arc<dyn Transport + Send + Sync>> =
2895 alloc::vec::Vec::new();
2896 let mut tcp_handle = None;
2897 for kind in &config.user_transports {
2898 let (leg, tcp) = select_user_transport(*kind, guid_prefix, domain_id, pinned)?;
2899 legs.push(leg);
2900 if tcp.is_some() {
2901 tcp_handle = tcp;
2902 }
2903 }
2904 let layered = Arc::new(crate::layered_transport::LayeredUserTransport::new(legs));
2905 (layered, tcp_handle)
2906 } else {
2907 let user_transport_kind = config
2908 .user_transport
2909 .or_else(parse_user_transport_env)
2910 .unwrap_or(UserTransportKind::UdpV4);
2911 select_user_transport(user_transport_kind, guid_prefix, domain_id, pinned)?
2912 };
2913
2914 // Separate sender socket for the SPDP announce. Ephemeral port; with
2915 // interface pinning it binds to the pinned IP (egress source), otherwise
2916 // `0.0.0.0` (the kernel picks the outgoing interface per route).
2917 let spdp_mc_tx =
2918 UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2919 label: "spdp mc-tx bind",
2920 })?;
2921
2922 let stop = Arc::new(AtomicBool::new(false));
2923
2924 // Materialize beacon locators for cross-host interop:
2925 // with a `0.0.0.0` bind address (UNSPECIFIED) the peer would
2926 // otherwise learn a non-routable address. We resolve UNSPECIFIED
2927 // via a UDP connect probe to a non-routable IP
2928 // (no traffic, just the routing table) and announce the
2929 // resulting local interface address — cross-host-capable
2930 // without an external crate dependency.
2931 let user_locator = announce_locator(&*user_uc, config.multicast_interface);
2932 let spdp_uc_locator = announce_locator(&spdp_uc, config.multicast_interface);
2933 let participant_data = ParticipantBuiltinTopicData {
2934 guid: Guid::new(guid_prefix, EntityId::PARTICIPANT),
2935 protocol_version: ProtocolVersion::V2_5,
2936 vendor_id: VendorId::ZERODDS,
2937 default_unicast_locator: Some(user_locator),
2938 default_multicast_locator: None,
2939 metatraffic_unicast_locator: Some(spdp_uc_locator),
2940 metatraffic_multicast_locator: Some(Locator {
2941 kind: LocatorKind::UdpV4,
2942 port: u32::from(spdp_port),
2943 address: {
2944 let mut a = [0u8; 16];
2945 a[12..].copy_from_slice(&config.spdp_multicast_group.octets());
2946 a
2947 },
2948 }),
2949 domain_id: Some(domain_id as u32),
2950 // We announce the endpoints we actually
2951 // implement: SPDP (participant ann/det) + SEDP
2952 // (publications/subscriptions ann+det) + WLP (10/11) +
2953 // TypeLookup service (12/13). Cyclone/Fast-DDS filter
2954 // their proxy setup by these flags — without them
2955 // we get no SEDP/WLP peers. SEDP topic
2956 // endpoints (bits 28/29) are optional per RTPS 2.5 §8.5.4.4
2957 // and covered in ZeroDDS via synthetic DCPSTopic
2958 // derivation from pub/sub — we do not announce them,
2959 // otherwise we promise peers a non-existent
2960 // endpoint pairing. When the caller sets
2961 // `announce_secure_endpoints = true` (security
2962 // factory path), we additionally mix in the 12 secure
2963 // discovery bits (16..27, DDS-Security 1.2 §7.4.7.1).
2964 builtin_endpoint_set: {
2965 let mut mask = endpoint_flag::ALL_STANDARD;
2966 if config.announce_secure_endpoints {
2967 mask |= endpoint_flag::ALL_SECURE;
2968 }
2969 mask
2970 },
2971 // Spec default lease = 100 s; configurable via
2972 // `RuntimeConfig::participant_lease_duration`.
2973 lease_duration: qos_duration_from_std(config.participant_lease_duration),
2974 // UserData on the participant — filled from
2975 // `DomainParticipantQos::user_data` via RuntimeConfig.
2976 user_data: config.user_data.clone(),
2977 // PROPERTY_LIST: security fills this with security caps
2978 // once a PolicyEngine is configured. Default-empty
2979 // stays backward-compatible with legacy peers.
2980 properties: Default::default(),
2981 // IdentityToken/PermissionsToken are filled by the security
2982 // layer once authentication + access control are
2983 // initialized. Default `None` = legacy announce.
2984 identity_token: None,
2985 permissions_token: None,
2986 identity_status_token: None,
2987 sig_algo_info: None,
2988 kx_algo_info: None,
2989 sym_cipher_algo_info: None,
2990 // Filled by the security layer (enable_security_builtins*) —
2991 // without PID_PARTICIPANT_SECURITY_INFO foreign vendors classify
2992 // us as non-secure. Default None = legacy/plain.
2993 participant_security_info: None,
2994 };
2995 let beacon = SpdpBeacon::new(participant_data.clone());
2996 let sedp = SedpStack::new(guid_prefix, VendorId::ZERODDS);
2997 // In-process discovery fastpath: remember the multicast group before
2998 // `config` is moved into the struct literal.
2999 let inproc_group = config.spdp_multicast_group;
3000
3001 #[cfg(feature = "security")]
3002 let outbound_pool = if config.interface_bindings.is_empty() {
3003 None
3004 } else {
3005 Some(Arc::new(OutboundSocketPool::bind_all(
3006 &config.interface_bindings,
3007 )?))
3008 };
3009
3010 // WLP endpoint (RTPS 2.5 §8.4.13). The tick period is explicit
3011 // `wlp_period`, or `lease/3` when `wlp_period == ZERO`
3012 // (spec recommendation: three misses before the reader marks the
3013 // writer as not-alive).
3014 let wlp_tick_period = if config.wlp_period.is_zero() {
3015 config.participant_lease_duration / 3
3016 } else {
3017 config.wlp_period
3018 };
3019 let wlp = crate::wlp::WlpEndpoint::new(guid_prefix, VendorId::ZERODDS, wlp_tick_period);
3020
3021 let rt = Arc::new(Self {
3022 guid_prefix,
3023 domain_id,
3024 spdp_multicast_rx: Arc::new(spdp_mc),
3025 spdp_unicast: Arc::new(spdp_uc),
3026 user_unicast: user_uc,
3027 user_announce_locator: user_locator,
3028 spdp_mc_tx: Arc::new(spdp_mc_tx),
3029 spdp_beacon: Mutex::new(beacon),
3030 participant_data,
3031 announced_pubs: Mutex::new(Vec::new()),
3032 announced_subs: Mutex::new(Vec::new()),
3033 spdp_reader: SpdpReader::new(),
3034 discovered: Arc::new(Mutex::new(DiscoveredParticipantsCache::new())),
3035 spdp_relay_cache: Mutex::new(alloc::collections::BTreeMap::new()),
3036 sedp: Arc::new(Mutex::new(sedp)),
3037 type_lookup_endpoints: TypeLookupEndpoints::new(guid_prefix),
3038 type_lookup_server: Arc::new(Mutex::new(TypeLookupServer::new())),
3039 type_lookup_client: Arc::new(Mutex::new(TypeLookupClient::new())),
3040 tl_reply_sn: core::sync::atomic::AtomicU64::new(0),
3041 security_builtin: Mutex::new(None),
3042 start_instant: Instant::now(),
3043 user_writers: Arc::new(RwLock::new(BTreeMap::new())),
3044 shm_locators: Arc::new(RwLock::new(BTreeMap::new())),
3045 same_host: Arc::new(crate::same_host::SameHostTracker::new()),
3046 user_readers: Arc::new(RwLock::new(BTreeMap::new())),
3047 #[cfg(feature = "security")]
3048 endpoint_tokens_sent: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
3049 #[cfg(feature = "security")]
3050 sedp_reannounced: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
3051 #[cfg(feature = "security")]
3052 endpoint_crypto: Arc::new(RwLock::new(alloc::collections::BTreeMap::new())),
3053 intra_runtime_routes: Arc::new(RwLock::new(BTreeMap::new())),
3054 entity_counter: AtomicU32::new(1),
3055 config,
3056 stop: stop.clone(),
3057 tick_seq: AtomicU64::new(0),
3058 spdp_announce_seq: AtomicU64::new(0),
3059 inconsistent_topic_seq: AtomicU64::new(0),
3060 tick_wake: Mutex::new(None),
3061 tick_wake_pending: AtomicBool::new(false),
3062 handles: Mutex::new(Vec::new()),
3063 match_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
3064 ack_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
3065 #[cfg(feature = "security")]
3066 outbound_pool,
3067 wlp: Arc::new(Mutex::new(wlp)),
3068 builtin_sinks: Mutex::new(None),
3069 ignore_filter: Mutex::new(None),
3070 });
3071
3072 // In-process discovery fastpath: register the runtime in the process
3073 // registry so same-process+domain peers find each other
3074 // deterministically (see `crate::inproc`). Right
3075 // after, `pull-on-creation`: pull all already-announced endpoints
3076 // of existing peers into our SEDP cache — otherwise
3077 // we see peers that announced endpoints BEFORE us
3078 // only via the (lossy) UDP SEDP path.
3079 crate::inproc::register(&rt, domain_id, inproc_group);
3080 rt.inproc_pull_from_peers();
3081
3082 // Per-socket recv threads + one tick thread (Sprint D.5b).
3083 //
3084 // Previously the entire stack ran in a single event loop
3085 // that went through three blocking `recv()`s with a `tick_period`
3086 // timeout (50 ms) sequentially per iteration. On a
3087 // roundtrip each stage waited up to 50 ms for timeouts of the
3088 // front sockets before its own datagram got its turn —
3089 // yielded 5-14 ms p50.
3090 //
3091 // Refit: every relevant recv path has its own thread
3092 // that sits directly blocking on its socket and dispatches
3093 // immediately when data arrives. The tick thread does the
3094 // periodic outbound work (HEARTBEAT/resend/ACKNACK/
3095 // SPDP announce/deadline/lifespan/liveliness) and sleeps
3096 // `tick_period` between iterations.
3097 //
3098 // Lock order (deadlock avoidance): the tick thread and
3099 // recv threads contend for `rt.sedp.lock()` / `rt.wlp.lock()`.
3100 // Convention: keep lock-hold times short (handle_datagram /
3101 // tick are both fast), do not take a sub-lock under the `sedp`
3102 // or `wlp` lock.
3103 let mut handles_init: Vec<JoinHandle<()>> = Vec::with_capacity(4);
3104
3105 let rt_recv_spdp_mc = Arc::clone(&rt);
3106 let stop_recv_spdp_mc = stop.clone();
3107 handles_init.push(
3108 thread::Builder::new()
3109 .name(String::from("zdds-recv-spdp-mc"))
3110 .spawn(move || recv_spdp_multicast_loop(rt_recv_spdp_mc, stop_recv_spdp_mc))
3111 .map_err(|_| DdsError::PreconditionNotMet {
3112 reason: "spawn zdds-recv-spdp-mc thread",
3113 })?,
3114 );
3115
3116 let rt_recv_meta = Arc::clone(&rt);
3117 let stop_recv_meta = stop.clone();
3118 handles_init.push(
3119 thread::Builder::new()
3120 .name(String::from("zdds-recv-meta"))
3121 .spawn(move || recv_metatraffic_loop(rt_recv_meta, stop_recv_meta))
3122 .map_err(|_| DdsError::PreconditionNotMet {
3123 reason: "spawn zdds-recv-meta thread",
3124 })?,
3125 );
3126
3127 let rt_recv_user = Arc::clone(&rt);
3128 let stop_recv_user = stop.clone();
3129 let primary_socket = Arc::clone(&rt.user_unicast);
3130 handles_init.push(
3131 thread::Builder::new()
3132 .name(String::from("zdds-recv-user"))
3133 .spawn(move || recv_user_data_loop(rt_recv_user, primary_socket, stop_recv_user))
3134 .map_err(|_| DdsError::PreconditionNotMet {
3135 reason: "spawn zdds-recv-user thread",
3136 })?,
3137 );
3138
3139 // TCPv4 variant: a separate accept worker (TcpTransport has
3140 // no implicit accept thread in the constructor — accept_one()
3141 // must be called explicitly).
3142 if let Some(tcp_arc) = tcp_accept_handle {
3143 let stop_accept = stop.clone();
3144 handles_init.push(
3145 thread::Builder::new()
3146 .name(String::from("zdds-tcp-accept"))
3147 .spawn(move || {
3148 while !stop_accept.load(Ordering::Relaxed) {
3149 // accept_one() blocks until connection +
3150 // handshake; on EOF it returns Ok(()) and
3151 // we accept the next peer.
3152 let _ = tcp_arc.accept_one();
3153 }
3154 })
3155 .map_err(|_| DdsError::PreconditionNotMet {
3156 reason: "spawn zdds-tcp-accept thread",
3157 })?,
3158 );
3159 }
3160
3161 // Opt-3 (Spec `zerodds-zero-copy-1.0` §9): additional
3162 // SO_REUSEPORT recv workers. Each binds to the same
3163 // user_unicast port; the kernel distributes incoming datagrams via
3164 // flow hash. On a bind error (e.g. a platform without
3165 // SO_REUSEPORT support) the worker is skipped and the
3166 // runtime continues with the available workers.
3167 if rt.config.extra_recv_threads > 0 {
3168 let user_port = u16::try_from(rt.user_unicast.local_locator().port).unwrap_or(0);
3169 // With an active security config, share the first interface bind address;
3170 // otherwise INADDR_ANY (the kernel chooses).
3171 #[cfg(feature = "security")]
3172 let bind_addr = rt
3173 .config
3174 .interface_bindings
3175 .first()
3176 .map(|spec| spec.bind_addr)
3177 .unwrap_or(Ipv4Addr::UNSPECIFIED);
3178 #[cfg(not(feature = "security"))]
3179 let bind_addr = Ipv4Addr::UNSPECIFIED;
3180 for i in 0..rt.config.extra_recv_threads {
3181 let extra_socket =
3182 match UdpTransport::bind_v4_reuse(bind_addr, user_port) {
3183 Ok(t) => Arc::new(t.with_timeout(Some(Duration::from_secs(1))).map_err(
3184 |_| DdsError::TransportError {
3185 label: "extra-recv set_timeout failed",
3186 },
3187 )?),
3188 Err(_) => break, // SO_REUSEPORT not available — skip.
3189 };
3190 let rt_extra = Arc::clone(&rt);
3191 let stop_extra = stop.clone();
3192 let name = format!("zdds-recv-user-{}", i + 1);
3193 handles_init.push(
3194 thread::Builder::new()
3195 .name(name)
3196 .spawn(move || recv_user_data_loop(rt_extra, extra_socket, stop_extra))
3197 .map_err(|_| DdsError::PreconditionNotMet {
3198 reason: "spawn zdds-recv-user-N thread",
3199 })?,
3200 );
3201 }
3202 }
3203
3204 // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6): per-owner
3205 // SHM recv loop. Polls all bound consumer entries of the
3206 // SameHostTracker round-robin and dispatches incoming
3207 // frames analogous to the UDP path. Only compiled when
3208 // the `same-host-shm` feature is on.
3209 #[cfg(feature = "same-host-shm")]
3210 {
3211 let rt_recv_shm = Arc::clone(&rt);
3212 let stop_recv_shm = stop.clone();
3213 handles_init.push(
3214 thread::Builder::new()
3215 .name(String::from("zdds-recv-shm"))
3216 .spawn(move || recv_user_shm_loop(rt_recv_shm, stop_recv_shm))
3217 .map_err(|_| DdsError::PreconditionNotMet {
3218 reason: "spawn zdds-recv-shm thread",
3219 })?,
3220 );
3221 }
3222
3223 // zerodds-async-1.0 §4: with `external_tick`, the tick loop is driven
3224 // by an external executor (tokio via `spawn_in_tokio`) rather than a
3225 // dedicated thread — so we skip the spawn here. `stop` is dropped; the
3226 // driver observes shutdown via `rt.stop` (set in `Drop`/`stop()`).
3227 if rt.config.external_tick {
3228 drop(stop);
3229 } else if rt.config.scheduler_tick {
3230 // D.5e Phase 3 — event-driven scheduler tick. Create the scheduler
3231 // up front, publish its wake handle so recv loops + the write path
3232 // can `raise_tick_wake`, then drive the (unchanged) tick from the
3233 // deadline-heap worker.
3234 let (scheduler, handle) =
3235 crate::scheduler::Scheduler::<TickEvent>::new(SCHEDULER_IDLE_FLOOR);
3236 if let Ok(mut g) = rt.tick_wake.lock() {
3237 *g = Some(handle.clone());
3238 }
3239 let rt_tick = Arc::clone(&rt);
3240 let stop_tick = stop;
3241 handles_init.push(
3242 thread::Builder::new()
3243 .name(String::from("zdds-tick-sched"))
3244 .spawn(move || scheduler_tick_loop(rt_tick, stop_tick, scheduler, handle))
3245 .map_err(|_| DdsError::PreconditionNotMet {
3246 reason: "spawn zdds-tick-sched thread",
3247 })?,
3248 );
3249 } else {
3250 let rt_tick = Arc::clone(&rt);
3251 let stop_tick = stop;
3252 handles_init.push(
3253 thread::Builder::new()
3254 .name(String::from("zdds-tick"))
3255 .spawn(move || tick_loop(rt_tick, stop_tick))
3256 .map_err(|_| DdsError::PreconditionNotMet {
3257 reason: "spawn zdds-tick thread",
3258 })?,
3259 );
3260 }
3261
3262 let mut guard = rt
3263 .handles
3264 .lock()
3265 .map_err(|_| DdsError::PreconditionNotMet {
3266 reason: "runtime handles mutex poisoned",
3267 })?;
3268 *guard = handles_init;
3269 drop(guard);
3270
3271 Ok(rt)
3272 }
3273
3274 /// Local unicast locator for user data (announced in SPDP).
3275 #[must_use]
3276 pub fn user_locator(&self) -> zerodds_rtps::wire_types::Locator {
3277 self.user_unicast.local_locator()
3278 }
3279
3280 /// Local unicast locator for SPDP metatraffic.
3281 #[must_use]
3282 pub fn spdp_unicast_locator(&self) -> zerodds_rtps::wire_types::Locator {
3283 self.spdp_unicast.local_locator()
3284 }
3285
3286 /// Returns the `BuiltinEndpointSet` bitmask that the runtime
3287 /// currently announces in the SPDP beacon. Used for tests + diagnostics;
3288 /// production consumers should decode the SPDP beacon
3289 /// themselves.
3290 #[must_use]
3291 pub fn announced_builtin_endpoint_set(&self) -> u32 {
3292 self.spdp_beacon
3293 .lock()
3294 .map(|b| b.data.builtin_endpoint_set)
3295 .unwrap_or(0)
3296 }
3297
3298 /// Registers a `TypeObject` in the local TypeLookup server
3299 /// registry. Other participants can then query this type via
3300 /// a `getTypes` request (XTypes 1.3 §7.6.3.3.4).
3301 ///
3302 /// Returns the `EquivalenceHash` of the registered type
3303 /// (the caller can embed it e.g. in `PublicationBuiltinTopicData` as a
3304 /// PID_TYPE_INFORMATION hint).
3305 ///
3306 /// # Errors
3307 /// `DdsError::PreconditionNotMet` on lock poisoning or a hash
3308 /// computation error.
3309 pub fn register_type_object(
3310 &self,
3311 obj: zerodds_types::type_object::TypeObject,
3312 ) -> Result<zerodds_types::EquivalenceHash> {
3313 let hash = zerodds_types::compute_hash(&obj).map_err(|_| DdsError::PreconditionNotMet {
3314 reason: "type hash computation failed",
3315 })?;
3316 let mut server =
3317 self.type_lookup_server
3318 .lock()
3319 .map_err(|_| DdsError::PreconditionNotMet {
3320 reason: "type_lookup_server mutex poisoned",
3321 })?;
3322 match obj {
3323 zerodds_types::type_object::TypeObject::Minimal(m) => {
3324 server.registry.insert_minimal(hash, m);
3325 }
3326 zerodds_types::type_object::TypeObject::Complete(c) => {
3327 server.registry.insert_complete(hash, c);
3328 }
3329 _ => {
3330 return Err(DdsError::PreconditionNotMet {
3331 reason: "unknown TypeObject variant",
3332 });
3333 }
3334 }
3335 Ok(hash)
3336 }
3337
3338 /// Sends a `getTypes` request to a discovered peer and
3339 /// returns a `RequestId` with which the caller can correlate the
3340 /// asynchronous reply later (XTypes 1.3
3341 /// §7.6.3.3.4 + `TypeLookupClient::handle_reply`).
3342 ///
3343 /// `peer` must be in `discovered_participants()` — otherwise
3344 /// `None` is returned (no known peer locator). On a
3345 /// successful send the request sample-identity sequence
3346 /// is returned as the `RequestId`; an incoming reply is correlated on
3347 /// this sequence ID.
3348 ///
3349 /// # Errors
3350 /// `DdsError::PreconditionNotMet` on encode errors or lock
3351 /// poisoning.
3352 pub fn send_type_lookup_request(
3353 &self,
3354 peer: zerodds_rtps::wire_types::GuidPrefix,
3355 type_hashes: &[zerodds_types::EquivalenceHash],
3356 ) -> Result<Option<zerodds_discovery::type_lookup::RequestId>> {
3357 use alloc::sync::Arc as AllocArc;
3358 use zerodds_discovery::type_lookup::request_types_payload;
3359 use zerodds_rtps::datagram::encode_data_datagram;
3360 use zerodds_rtps::header::RtpsHeader;
3361 use zerodds_rtps::submessages::DataSubmessage;
3362 use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber};
3363
3364 // Find peer's user-unicast locator (default-unicast first;
3365 // fallback metatraffic-unicast). TypeLookup datagrams go via
3366 // the user-unicast path — the peer DCPS runtime has a
3367 // shared receive loop there for SEDP/user data/TypeLookup.
3368 let target = {
3369 let discovered = self
3370 .discovered
3371 .lock()
3372 .map_err(|_| DdsError::PreconditionNotMet {
3373 reason: "discovered mutex poisoned",
3374 })?;
3375 let Some(dp) = discovered.get(&peer) else {
3376 return Ok(None);
3377 };
3378 dp.data
3379 .default_unicast_locator
3380 .or(dp.data.metatraffic_unicast_locator)
3381 };
3382 let Some(target) = target else {
3383 return Ok(None);
3384 };
3385
3386 // Allocate RequestId (client-side incrementing sequence). Reply
3387 // correlation runs via the `handle_reply` callback. We
3388 // register a callback that feeds the returned
3389 // TypeObjects into the local `TypeLookupServer.registry`
3390 // (XTypes 1.3 §7.6.3.3.4): hash-by-hash, separately
3391 // for Minimal and Complete variants. This way a hash that
3392 // was resolved once is recognized for future `has_type_for_hash`
3393 // checks (= no re-requests).
3394 let mut client =
3395 self.type_lookup_client
3396 .lock()
3397 .map_err(|_| DdsError::PreconditionNotMet {
3398 reason: "type_lookup_client mutex poisoned",
3399 })?;
3400 let type_ids: alloc::vec::Vec<zerodds_types::TypeIdentifier> = type_hashes
3401 .iter()
3402 .map(|h| zerodds_types::TypeIdentifier::EquivalenceHashMinimal(*h))
3403 .collect();
3404 let server_for_cb = Arc::clone(&self.type_lookup_server);
3405 let cb = Box::new(
3406 move |reply: zerodds_discovery::type_lookup::TypeLookupReply| {
3407 let zerodds_discovery::type_lookup::TypeLookupReply::Types(types_reply) = reply
3408 else {
3409 return;
3410 };
3411 let Ok(mut server) = server_for_cb.lock() else {
3412 return;
3413 };
3414 for t in &types_reply.types {
3415 match t {
3416 zerodds_types::type_lookup::ReplyTypeObject::Minimal(m) => {
3417 let to = zerodds_types::type_object::TypeObject::Minimal(m.clone());
3418 if let Ok(h) = zerodds_types::compute_hash(&to) {
3419 server.registry.insert_minimal(h, m.clone());
3420 }
3421 }
3422 zerodds_types::type_lookup::ReplyTypeObject::Complete(c) => {
3423 let to = zerodds_types::type_object::TypeObject::Complete(c.clone());
3424 if let Ok(h) = zerodds_types::compute_hash(&to) {
3425 server.registry.insert_complete(h, c.clone());
3426 }
3427 }
3428 }
3429 }
3430 },
3431 );
3432 let request_id = client.request_types(type_ids.clone(), cb);
3433 drop(client);
3434
3435 // Encode the wire request payload (PL_CDR_LE-Encapsulation).
3436 let body = request_types_payload(&type_ids).map_err(|_| DdsError::PreconditionNotMet {
3437 reason: "type_lookup request payload encode failed",
3438 })?;
3439 let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
3440 payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
3441 payload.extend_from_slice(&body);
3442
3443 // Use the RequestId as the writer_sn so the peer-side reply can
3444 // echo it for correlation (XTypes §7.6.3.3.3 Sample-Identity).
3445 let id_u64 = request_id.0;
3446 let sn =
3447 SequenceNumber::from_high_low((id_u64 >> 32) as i32, (id_u64 & 0xFFFF_FFFF) as u32);
3448 let header = RtpsHeader {
3449 protocol_version: ProtocolVersion::CURRENT,
3450 vendor_id: VendorId::ZERODDS,
3451 guid_prefix: self.guid_prefix,
3452 };
3453 let data = DataSubmessage {
3454 extra_flags: 0,
3455 reader_id: EntityId::TL_SVC_REQ_READER,
3456 writer_id: EntityId::TL_SVC_REQ_WRITER,
3457 writer_sn: sn,
3458 inline_qos: None,
3459 key_flag: false,
3460 non_standard_flag: false,
3461 serialized_payload: AllocArc::from(payload.into_boxed_slice()),
3462 };
3463 let datagram =
3464 encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
3465 reason: "type_lookup request datagram encode failed",
3466 })?;
3467
3468 if is_routable_user_locator(&target) {
3469 let _ = self.user_unicast.send(&target, &datagram);
3470 }
3471 Ok(Some(request_id))
3472 }
3473
3474 /// Activates the security builtin endpoint stack
3475 /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
3476 /// MessageSecure`). Typically called by the factory
3477 /// once a security plugin is registered on the participant.
3478 /// Idempotent: a second call has no effect. Returns the (possibly
3479 /// freshly created) stack handle.
3480 pub fn enable_security_builtins(
3481 &self,
3482 vendor_id: VendorId,
3483 ) -> Arc<Mutex<SecurityBuiltinStack>> {
3484 self.install_security_stack(SecurityBuiltinStack::new(self.guid_prefix, vendor_id))
3485 }
3486
3487 /// Like [`enable_security_builtins`](Self::enable_security_builtins),
3488 /// but with an active auth-handshake driver (FU2 Gap 4). The stack
3489 /// is built via [`SecurityBuiltinStack::with_auth`]: the shared
3490 /// `auth` plugin (= the same instance that hangs on the crypto gate as
3491 /// the `SharedSecretProvider`) drives the PKI handshake as soon as
3492 /// a peer with stateless bits + identity token is discovered.
3493 ///
3494 /// `local_identity` comes from `validate_local_identity`; the local
3495 /// 16-byte participant GUID is derived from the `guid_prefix`.
3496 ///
3497 /// Idempotent (first-wins): if a stack is already active — even one
3498 /// built without auth — that one is returned and the freshly
3499 /// built one discarded.
3500 #[cfg(feature = "security")]
3501 pub fn enable_security_builtins_with_auth(
3502 self: &Arc<Self>,
3503 vendor_id: VendorId,
3504 auth: Arc<Mutex<dyn zerodds_security::authentication::AuthenticationPlugin>>,
3505 local_identity: zerodds_security::authentication::IdentityHandle,
3506 ) -> Arc<Mutex<SecurityBuiltinStack>> {
3507 let local_guid = Guid::new(self.guid_prefix, EntityId::PARTICIPANT).to_bytes();
3508 // Announce the local IdentityToken in the SPDP beacon (PID_IDENTITY_TOKEN,
3509 // FU2 Gap 7c) + set the stateless/volatile-secure bits, so peers
3510 // initiate the auth handshake. Before moving `auth` into the stack.
3511 if let Ok(mut plugin) = auth.lock() {
3512 if let Ok(token) = plugin.get_identity_token(local_identity) {
3513 // PID_PERMISSIONS_TOKEN (§7.4.1.5, S4 point 1): secure
3514 // vendors (cyclone/FastDDS) start validate_remote_identity
3515 // only when SPDP carries identity_token AND permissions_token;
3516 // otherwise we stay non-secure and all endpoints are "not
3517 // allowed". Empty if no permissions are configured.
3518 let perm_token = plugin.get_permissions_token();
3519 let pdata = if let Ok(mut beacon) = self.spdp_beacon.lock() {
3520 if !token.is_empty() {
3521 beacon.data.identity_token = Some(token);
3522 }
3523 if !perm_token.is_empty() {
3524 beacon.data.permissions_token = Some(perm_token);
3525 }
3526 // Full secure builtin endpoint set (§7.4.7.1): stateless +
3527 // VolatileSecure (22-25) PLUS secure SEDP (16-19),
3528 // secure ParticipantMessage (20-21) and DCPSParticipantsSecure
3529 // (26-27). cyclone starts validate_remote_identity + creates the
3530 // secure builtin proxies ONLY when the remote announces the full
3531 // secure set (cyclone-trace-verified) — only
3532 // 22-25 → "Non secure remote ... not allowed", no handshake.
3533 beacon.data.builtin_endpoint_set |= endpoint_flag::PUBLICATIONS_SECURE_WRITER
3534 | endpoint_flag::PUBLICATIONS_SECURE_READER
3535 | endpoint_flag::SUBSCRIPTIONS_SECURE_WRITER
3536 | endpoint_flag::SUBSCRIPTIONS_SECURE_READER
3537 | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_WRITER
3538 | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_READER
3539 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
3540 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
3541 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
3542 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
3543 | endpoint_flag::PARTICIPANT_SECURE_WRITER
3544 | endpoint_flag::PARTICIPANT_SECURE_READER;
3545 // PID_PARTICIPANT_SECURITY_INFO (§7.4.1.6): marks us as a
3546 // secure participant — mandatory, otherwise cyclone/
3547 // FastDDS treat us as non-secure and reject all endpoints.
3548 // IS_VALID on both masks; derive the participant-level
3549 // ParticipantSecurityAttributes (§9.4.2.4) from the governance:
3550 // is_{rtps,discovery,liveliness}_protected in the
3551 // attr mask, is_*_encrypted in the plugin mask. cyclone
3552 // matches the announced bits against its own governance —
3553 // a null mask with e.g. discovery=ENCRYPT is a policy
3554 // mismatch and cyclone then establishes NO secured
3555 // participant crypto handshake (bug source protected discovery).
3556 use zerodds_rtps::participant_security_info::{
3557 ParticipantSecurityInfo, attrs, plugin_attrs,
3558 };
3559 let (mut a, mut p) = (attrs::IS_VALID, plugin_attrs::IS_VALID);
3560 if let Some(gate) = self.config.security.as_ref() {
3561 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
3562 let disc = gate.discovery_protection().unwrap_or(ProtectionLevel::None);
3563 let live = gate
3564 .liveliness_protection()
3565 .unwrap_or(ProtectionLevel::None);
3566 if rtps != ProtectionLevel::None {
3567 a |= attrs::IS_RTPS_PROTECTED;
3568 }
3569 if disc != ProtectionLevel::None {
3570 a |= attrs::IS_DISCOVERY_PROTECTED;
3571 }
3572 if live != ProtectionLevel::None {
3573 a |= attrs::IS_LIVELINESS_PROTECTED;
3574 }
3575 if rtps == ProtectionLevel::Encrypt {
3576 p |= plugin_attrs::IS_RTPS_ENCRYPTED;
3577 }
3578 if disc == ProtectionLevel::Encrypt {
3579 p |= plugin_attrs::IS_DISCOVERY_ENCRYPTED;
3580 }
3581 if live == ProtectionLevel::Encrypt {
3582 p |= plugin_attrs::IS_LIVELINESS_ENCRYPTED;
3583 }
3584 }
3585 beacon.data.participant_security_info = Some(ParticipantSecurityInfo {
3586 participant_security_attributes: a,
3587 plugin_participant_security_attributes: p,
3588 });
3589 // c.pdata (§9.3.2.5.2, S4 root 6+7): our own
3590 // ParticipantBuiltinTopicData as PL_CDR_**BE** — the replier
3591 // (cyclone) deserializes c.pdata strictly as a big-endian
3592 // ParameterList and binds the participant_guid to the
3593 // authenticated identity. LE → "payload too long".
3594 Some(beacon.data.to_pl_cdr_be())
3595 } else {
3596 None
3597 };
3598 if let Some(pd) = pdata {
3599 plugin.set_local_participant_data(pd);
3600 }
3601 }
3602 }
3603 let stack = self.install_security_stack(SecurityBuiltinStack::with_auth(
3604 self.guid_prefix,
3605 vendor_id,
3606 auth,
3607 local_identity,
3608 local_guid,
3609 ));
3610 // FU2 S3: kick off in-process participant discovery + handshake trigger
3611 // deterministically — decouples the secured discovery from the
3612 // flaky multicast path (codepit LXC). Bidirectional, idempotent.
3613 self.inproc_announce_participant();
3614 // FU2 S3: immediate token-carrying SPDP re-announce (event-driven).
3615 self.announce_spdp_now();
3616 stack
3617 }
3618
3619 /// Installs a freshly built `SecurityBuiltinStack` into the
3620 /// runtime slot (idempotent) and catches up on peers already
3621 /// discovered via SPDP. Shared core of
3622 /// [`enable_security_builtins`](Self::enable_security_builtins) and
3623 /// [`enable_security_builtins_with_auth`](Self::enable_security_builtins_with_auth).
3624 fn install_security_stack(
3625 &self,
3626 fresh: SecurityBuiltinStack,
3627 ) -> Arc<Mutex<SecurityBuiltinStack>> {
3628 // Lock poisoning is a bug indicator here (an earlier panic in the
3629 // hot path). In that case we return a fresh, isolated
3630 // stack — the caller gets at least a
3631 // functional slot, but the hot path writes its mutations
3632 // into the unlocked original. In production code this does not happen;
3633 // in tests (where poisoning can occur) this is a
3634 // best-effort recovery.
3635 let mut slot = match self.security_builtin.lock() {
3636 Ok(g) => g,
3637 Err(_) => {
3638 return Arc::new(Mutex::new(fresh));
3639 }
3640 };
3641 if let Some(existing) = slot.as_ref() {
3642 return Arc::clone(existing);
3643 }
3644 let stack = Arc::new(Mutex::new(fresh));
3645 // Catch up on already-discovered peers (discovery may have already
3646 // seen SPDP beacons before the plugin was activated).
3647 if let Ok(cache) = self.discovered.lock() {
3648 if let Ok(mut s) = stack.lock() {
3649 for peer in cache.iter() {
3650 s.handle_remote_endpoints(peer);
3651 }
3652 }
3653 }
3654 *slot = Some(Arc::clone(&stack));
3655 // Protected discovery (DDS-Security §8.4.2.4): if the governance demands
3656 // `discovery_protection_kind != NONE`, the SedpStack routes secured
3657 // endpoints via the secure SEDP (DCPSPublicationsSecure/Subscriptions
3658 // Secure) instead of plaintext — the runtime send path protects their DATA/
3659 // HEARTBEAT/GAP with the participant data key. Set before the first
3660 // announce_* (endpoint creation follows the security activation).
3661 #[cfg(feature = "security")]
3662 if let Some(gate) = self.config.security.as_ref() {
3663 let protected = gate
3664 .discovery_protection()
3665 .map(|l| l != ProtectionLevel::None)
3666 .unwrap_or(false);
3667 if protected {
3668 if let Ok(mut sedp) = self.sedp.lock() {
3669 sedp.set_discovery_protected(true);
3670 }
3671 }
3672 }
3673 stack
3674 }
3675
3676 /// Snapshot handle on the security builtin stack. `None` if
3677 /// [`enable_security_builtins`](Self::enable_security_builtins)
3678 /// has not been called yet.
3679 #[must_use]
3680 pub fn security_builtin_snapshot(&self) -> Option<Arc<Mutex<SecurityBuiltinStack>>> {
3681 self.security_builtin.lock().ok()?.as_ref().map(Arc::clone)
3682 }
3683
3684 /// `assert_liveliness()` on the `DomainParticipant` (DCPS 1.4
3685 /// §2.2.3.11 MANUAL_BY_PARTICIPANT). Sends exactly one WLP heartbeat
3686 /// with `kind = MANUAL_BY_PARTICIPANT` on the next tick;
3687 /// all readers matching this participant refresh their
3688 /// last-seen timestamp. Idempotent — multiple calls within
3689 /// one tick period result in multiple wire sends up to the
3690 /// cap (`MAX_QUEUED_PULSES = 32`).
3691 pub fn assert_liveliness(&self) {
3692 if let Ok(mut wlp) = self.wlp.lock() {
3693 wlp.assert_participant();
3694 }
3695 }
3696
3697 /// `assert_liveliness()` on a `DataWriter` (DCPS 1.4 §2.2.3.11
3698 /// MANUAL_BY_TOPIC). `topic_token` is an opaque token that
3699 /// matching readers can use to associate the pulse with a concrete
3700 /// topic. We use the ZeroDDS vendor kind (Cyclone /
3701 /// Fast-DDS ignore the vendor kind, which is spec-conformant —
3702 /// MSB-set in `kind` requests "ignore unknown" behavior).
3703 pub fn assert_writer_liveliness(&self, topic_token: Vec<u8>) {
3704 if let Ok(mut wlp) = self.wlp.lock() {
3705 wlp.assert_topic(topic_token);
3706 }
3707 }
3708
3709 /// Current WLP last-seen timestamp of a remote peer (relative
3710 /// to runtime start). `None` if the peer has not sent a WLP
3711 /// heartbeat yet.
3712 #[must_use]
3713 pub fn peer_liveliness_last_seen(&self, prefix: &GuidPrefix) -> Option<Duration> {
3714 self.wlp
3715 .lock()
3716 .ok()
3717 .and_then(|w| w.peer_state(prefix).map(|s| s.last_seen))
3718 }
3719
3720 /// Returns the [`zerodds_discovery::PeerCapabilities`] of a remote
3721 /// peer, based on its most recently received SPDP beacon.
3722 /// `None` if the peer has not been discovered via SPDP yet.
3723 #[must_use]
3724 pub fn peer_capabilities(
3725 &self,
3726 prefix: &GuidPrefix,
3727 ) -> Option<zerodds_discovery::PeerCapabilities> {
3728 self.discovered
3729 .lock()
3730 .ok()
3731 .and_then(|d| d.get(prefix).map(|p| p.data.builtin_endpoint_set))
3732 .map(zerodds_discovery::PeerCapabilities::from_bits)
3733 }
3734
3735 /// Snapshot of the currently discovered remote participants.
3736 /// Key = GUID prefix, value = last seen beacon content.
3737 #[must_use]
3738 pub fn discovered_participants(&self) -> Vec<DiscoveredParticipant> {
3739 self.discovered
3740 .lock()
3741 .map(|cache| cache.iter().cloned().collect())
3742 .unwrap_or_default()
3743 }
3744
3745 /// Wires the `BuiltinSinks` of the `DomainParticipant` into the
3746 /// discovery hot path. From this
3747 /// call on, all SPDP/SEDP receive events land as samples in
3748 /// the 4 builtin-topic readers.
3749 ///
3750 /// Called by the `DomainParticipant` constructor exactly once during
3751 /// setup.
3752 pub fn attach_builtin_sinks(&self, sinks: crate::builtin_subscriber::BuiltinSinks) {
3753 if let Ok(mut guard) = self.builtin_sinks.lock() {
3754 *guard = Some(sinks);
3755 }
3756 }
3757
3758 /// Snapshot of the currently wired BuiltinSinks (internal, for the
3759 /// hot path).
3760 pub(crate) fn builtin_sinks_snapshot(&self) -> Option<crate::builtin_subscriber::BuiltinSinks> {
3761 self.builtin_sinks.lock().ok().and_then(|g| g.clone())
3762 }
3763
3764 /// Wires the `IgnoreFilter` of the `DomainParticipant` into the
3765 /// discovery hot path. From
3766 /// this call on, SPDP/SEDP receive events are checked against the
3767 /// filter before being pushed as a builtin sample or used as an
3768 /// SEDP match source.
3769 ///
3770 /// Called by the `DomainParticipant` constructor exactly once during
3771 /// setup.
3772 pub fn attach_ignore_filter(&self, filter: crate::participant::IgnoreFilter) {
3773 if let Ok(mut guard) = self.ignore_filter.lock() {
3774 *guard = Some(filter);
3775 }
3776 }
3777
3778 /// Snapshot of the currently wired IgnoreFilter (internal, for
3779 /// the hot path).
3780 pub(crate) fn ignore_filter_snapshot(&self) -> Option<crate::participant::IgnoreFilter> {
3781 self.ignore_filter.lock().ok().and_then(|g| g.clone())
3782 }
3783
3784 /// Synchronizes the protected-discovery flag of the `SedpStack` with the
3785 /// governance (`discovery_protection_kind`). Idempotent, called before every
3786 /// `announce_*` — so the flag is set correctly regardless of the
3787 /// order in which security activation and endpoint creation ran.
3788 #[cfg(feature = "security")]
3789 fn sync_sedp_discovery_protected(&self, sedp: &mut SedpStack) {
3790 if let Some(gate) = self.config.security.as_ref() {
3791 let protected = gate
3792 .discovery_protection()
3793 .map(|l| l != ProtectionLevel::None)
3794 .unwrap_or(false);
3795 sedp.set_discovery_protected(protected);
3796 }
3797 }
3798
3799 /// Announces a local publication via SEDP. The runtime
3800 /// sends the generated datagrams immediately to all already-
3801 /// discovered remote participants.
3802 ///
3803 /// # Errors
3804 /// `WireError` if encoding fails.
3805 pub fn announce_publication(
3806 &self,
3807 data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3808 ) -> Result<()> {
3809 // In-process discovery fastpath: put it in the stash so a
3810 // peer runtime starting later in the same process can pull us
3811 // via `inproc_snapshot`.
3812 if let Ok(mut v) = self.announced_pubs.lock() {
3813 v.push(data.clone());
3814 }
3815 // ADR-0006: side-map lookup. If the local user writer has a
3816 // same-host backend attached (set_shm_locator was
3817 // called), we inject PID_SHM_LOCATOR into the SEDP
3818 // sample. Otherwise pure 1:1 spec wire.
3819 let shm = self.shm_locator(data.key.entity_id);
3820 let datagrams = {
3821 let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3822 reason: "sedp poisoned",
3823 })?;
3824 // Protected discovery (§8.4.2.4): set robustly before the announce —
3825 // independent of the order of enable_security_builtins vs.
3826 // endpoint creation. `discovery_protection_kind != NONE` routes
3827 // the announce into the secure SEDP writer.
3828 #[cfg(feature = "security")]
3829 self.sync_sedp_discovery_protected(&mut sedp);
3830 let res = if let Some(ref bytes) = shm {
3831 sedp.announce_publication_with_shm_locator(data, bytes)
3832 } else {
3833 sedp.announce_publication(data)
3834 };
3835 res.map_err(|_| DdsError::WireError {
3836 message: alloc::string::String::from("sedp announce_publication"),
3837 })?
3838 };
3839 // Send outside the lock (Rc<Vec<Locator>> is !Send,
3840 // but we are on the same thread as `self` — no
3841 // problem).
3842 for dg in datagrams {
3843 if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3844 for t in dg.targets.iter() {
3845 if is_routable_user_locator(t) {
3846 // §8.3.7: unicast metatraffic (SEDP DATA to the remote
3847 // metatraffic_unicast_locator) MUST go out from the metatraffic
3848 // recv socket `spdp_unicast`, NOT from the ephemeral
3849 // `spdp_mc_tx` — otherwise the peer sees a foreign
3850 // source port and sends its reliable ACKNACK/resends
3851 // to a dead port (cross-vendor SEDP stall). Identical
3852 // to `send_discovery_datagram`.
3853 let _ = self.spdp_unicast.send(t, &secured);
3854 }
3855 }
3856 }
3857 }
3858 // In-process discovery fastpath: serve same-process+domain peers
3859 // synchronously + losslessly with this publication.
3860 self.inproc_announce_publication(data);
3861 Ok(())
3862 }
3863
3864 /// Announces a local subscription via SEDP. Analogous to
3865 /// `announce_publication`.
3866 ///
3867 /// # Errors
3868 /// `WireError` if encoding fails.
3869 pub fn announce_subscription(
3870 &self,
3871 data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3872 ) -> Result<()> {
3873 if let Ok(mut v) = self.announced_subs.lock() {
3874 v.push(data.clone());
3875 }
3876 let datagrams = {
3877 let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3878 reason: "sedp poisoned",
3879 })?;
3880 #[cfg(feature = "security")]
3881 self.sync_sedp_discovery_protected(&mut sedp);
3882 sedp.announce_subscription(data)
3883 .map_err(|_| DdsError::WireError {
3884 message: alloc::string::String::from("sedp announce_subscription"),
3885 })?
3886 };
3887 for dg in datagrams {
3888 if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3889 for t in dg.targets.iter() {
3890 if is_routable_user_locator(t) {
3891 // Source port: metatraffic recv socket, not spdp_mc_tx
3892 // (see announce_publication / send_discovery_datagram).
3893 let _ = self.spdp_unicast.send(t, &secured);
3894 }
3895 }
3896 }
3897 }
3898 // In-process discovery fastpath: see `announce_publication`.
3899 self.inproc_announce_subscription(data);
3900 Ok(())
3901 }
3902
3903 /// Re-announces the local SEDP endpoint records (publications +
3904 /// subscriptions) to a peer whose crypto-token exchange has just
3905 /// completed. Background: under `rtps_protection`/`discovery_
3906 /// protection` ZeroDDS wraps the SEDP message-/submessage-protected; the
3907 /// peer discards the initial SEDP burst UNTIL it has our participant crypto
3908 /// token (via Volatile). From that moment it can decode — a
3909 /// one-time re-announce brings the previously dropped SEDP up (mints fresh
3910 /// SNs; the reliable SEDP writer delivers them, HEARTBEAT/NACK retry covers a
3911 /// not-quite-ready peer timing). Once per peer (dedup).
3912 ///
3913 /// No-op without active rtps_/discovery_protection (then the announce
3914 /// went through plaintext anyway) and for already re-announced peers. Emits
3915 /// the RETAINED records directly (NO additional `announced_pubs` push).
3916 #[cfg(feature = "security")]
3917 fn re_announce_sedp_to_peer(&self, peer_prefix: GuidPrefix) {
3918 let Some(gate) = &self.config.security else {
3919 return;
3920 };
3921 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3922 let disc =
3923 gate.discovery_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3924 if !rtps && !disc {
3925 return;
3926 }
3927 // First check whether we have any local endpoints at all — the token
3928 // exchange can complete BEFORE the user endpoint creation.
3929 // Without records do NOT mark as "re-announced" (the periodic tick
3930 // retriggers as soon as the user writer/reader is announced).
3931 let pubs = self
3932 .announced_pubs
3933 .lock()
3934 .map(|v| v.clone())
3935 .unwrap_or_default();
3936 let subs = self
3937 .announced_subs
3938 .lock()
3939 .map(|v| v.clone())
3940 .unwrap_or_default();
3941 if pubs.is_empty() && subs.is_empty() {
3942 return;
3943 }
3944 {
3945 let mut set = match self.sedp_reannounced.write() {
3946 Ok(s) => s,
3947 Err(_) => return,
3948 };
3949 if !set.insert(peer_prefix.0) {
3950 return; // already re-announced
3951 }
3952 }
3953 let send_dgs = |dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>| {
3954 for dg in dgs {
3955 if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3956 for t in dg.targets.iter() {
3957 if is_routable_user_locator(t) {
3958 let _ = self.spdp_unicast.send(t, &secured);
3959 }
3960 }
3961 }
3962 }
3963 };
3964 for data in &pubs {
3965 let shm = self.shm_locator(data.key.entity_id);
3966 let dgs = {
3967 let Ok(mut sedp) = self.sedp.lock() else {
3968 continue;
3969 };
3970 self.sync_sedp_discovery_protected(&mut sedp);
3971 let res = if let Some(ref bytes) = shm {
3972 sedp.announce_publication_with_shm_locator(data, bytes)
3973 } else {
3974 sedp.announce_publication(data)
3975 };
3976 match res {
3977 Ok(d) => d,
3978 Err(_) => continue,
3979 }
3980 };
3981 send_dgs(dgs);
3982 }
3983 for data in &subs {
3984 let dgs = {
3985 let Ok(mut sedp) = self.sedp.lock() else {
3986 continue;
3987 };
3988 self.sync_sedp_discovery_protected(&mut sedp);
3989 match sedp.announce_subscription(data) {
3990 Ok(d) => d,
3991 Err(_) => continue,
3992 }
3993 };
3994 send_dgs(dgs);
3995 }
3996 }
3997
3998 /// Own participant data as a `DiscoveredParticipant` — the
3999 /// self-view that the in-process fastpath hands to peers.
4000 fn self_as_discovered_participant(&self) -> zerodds_discovery::spdp::DiscoveredParticipant {
4001 // From the LIVE SPDP beacon: after `enable_security_builtins_with_auth`
4002 // it carries the `identity_token` + the secure endpoint bits that the
4003 // `participant_data` construction snapshot does NOT have. Without these the
4004 // in-process injected DP is worthless for the security handshake trigger
4005 // (`handle_remote_endpoints`/`begin_handshake_with` need
4006 // the token). Fallback to `participant_data` on lock poisoning.
4007 let data = self
4008 .spdp_beacon
4009 .lock()
4010 .map(|b| b.data.clone())
4011 .unwrap_or_else(|_| self.participant_data.clone());
4012 zerodds_discovery::spdp::DiscoveredParticipant {
4013 sender_prefix: self.guid_prefix,
4014 sender_vendor: VendorId::ZERODDS,
4015 data,
4016 }
4017 }
4018
4019 /// In-process discovery: injects the just-announced publication
4020 /// synchronously into all same-process+domain peer runtimes.
4021 fn inproc_announce_publication(
4022 &self,
4023 data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
4024 ) {
4025 let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4026 let mut dp = None;
4027 for peer in peers {
4028 if peer.guid_prefix == self.guid_prefix {
4029 continue;
4030 }
4031 let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
4032 peer.inproc_inject_publication(dp, data);
4033 }
4034 }
4035
4036 /// In-process discovery: injects the just-announced subscription
4037 /// synchronously into all same-process+domain peer runtimes.
4038 fn inproc_announce_subscription(
4039 &self,
4040 data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4041 ) {
4042 let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4043 let mut dp = None;
4044 for peer in peers {
4045 if peer.guid_prefix == self.guid_prefix {
4046 continue;
4047 }
4048 let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
4049 peer.inproc_inject_subscription(dp, data);
4050 }
4051 }
4052
4053 /// In-process discovery (receive side): wires the remote
4054 /// participant + injects the publication into the SEDP cache and
4055 /// matches the local readers. Idempotent — an announcement arriving
4056 /// later via UDP is thereby a no-op.
4057 fn inproc_inject_publication(
4058 self: &Arc<Self>,
4059 dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4060 data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
4061 ) {
4062 // §2.2.2.2.1.17: an ignored publication/participant must not be matched.
4063 // The in-process fastpath bypasses the wire match path, so the ignore
4064 // filter must be honored here too — otherwise the Durability-Service's
4065 // own two participants (ingest + replay, same process) would match and
4066 // echo-loop despite mutually ignoring each other.
4067 if let Some(filter) = self.ignore_filter_snapshot() {
4068 let pub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
4069 let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
4070 if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
4071 return;
4072 }
4073 }
4074 let now = self.start_instant.elapsed();
4075 let is_new = self
4076 .discovered
4077 .lock()
4078 .map(|mut c| c.insert(dp.clone()))
4079 .unwrap_or(false);
4080 if let Ok(mut sedp) = self.sedp.lock() {
4081 if is_new {
4082 sedp.on_participant_discovered(dp);
4083 }
4084 sedp.cache_mut().insert_publication(data.clone(), now);
4085 }
4086 run_matching_pass(self);
4087 }
4088
4089 /// In-process discovery (receive side): like `inproc_inject_publication`
4090 /// for a subscription.
4091 fn inproc_inject_subscription(
4092 self: &Arc<Self>,
4093 dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4094 data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4095 ) {
4096 // See `inproc_inject_publication`: honor the ignore filter on the
4097 // in-process fastpath (symmetric, subscription side).
4098 if let Some(filter) = self.ignore_filter_snapshot() {
4099 let sub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
4100 let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
4101 if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
4102 return;
4103 }
4104 }
4105 let now = self.start_instant.elapsed();
4106 let is_new = self
4107 .discovered
4108 .lock()
4109 .map(|mut c| c.insert(dp.clone()))
4110 .unwrap_or(false);
4111 if let Ok(mut sedp) = self.sedp.lock() {
4112 if is_new {
4113 sedp.on_participant_discovered(dp);
4114 }
4115 sedp.cache_mut().insert_subscription(data.clone(), now);
4116 }
4117 run_matching_pass(self);
4118 }
4119
4120 /// Snapshot of our own endpoints for the `pull-on-creation` path
4121 /// of a peer runtime starting later in the same process.
4122 fn inproc_snapshot(
4123 &self,
4124 ) -> (
4125 zerodds_discovery::spdp::DiscoveredParticipant,
4126 Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>,
4127 Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>,
4128 ) {
4129 let dp = self.self_as_discovered_participant();
4130 let pubs = self
4131 .announced_pubs
4132 .lock()
4133 .map(|v| v.clone())
4134 .unwrap_or_default();
4135 let subs = self
4136 .announced_subs
4137 .lock()
4138 .map(|v| v.clone())
4139 .unwrap_or_default();
4140 (dp, pubs, subs)
4141 }
4142
4143 /// At runtime creation: ask existing same-process+domain peers
4144 /// for their already-announced endpoints and inject these into
4145 /// our SEDP cache. Symmetric counterpart to the
4146 /// announce hook (which distributes live endpoints to peers).
4147 fn inproc_pull_from_peers(self: &Arc<Self>) {
4148 let peers: Vec<Arc<DcpsRuntime>> =
4149 crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
4150 .into_iter()
4151 .filter(|rt| rt.guid_prefix != self.guid_prefix)
4152 .collect();
4153 for peer in peers {
4154 let (dp, pubs, subs) = peer.inproc_snapshot();
4155 for p in &pubs {
4156 self.inproc_inject_publication(&dp, p);
4157 }
4158 for s in &subs {
4159 self.inproc_inject_subscription(&dp, s);
4160 }
4161 }
4162 }
4163
4164 /// FU2 S3: in-process counterpart to the security part of
4165 /// [`handle_spdp_datagram`]. Wires the secure builtin endpoints of the
4166 /// discovered peer and kicks off — if it announces an `identity_token`
4167 /// — the auth handshake; the resulting AUTH datagrams
4168 /// go to the peer via UDP **unicast** (reliable loopback).
4169 /// No-op without a local security stack or without a peer `identity_token`.
4170 #[cfg(feature = "security")]
4171 fn inproc_drive_security_handshake(
4172 self: &Arc<Self>,
4173 dp: &zerodds_discovery::spdp::DiscoveredParticipant,
4174 ) {
4175 if dp.sender_prefix == self.guid_prefix {
4176 return;
4177 }
4178 let Some(sec) = self.security_builtin_snapshot() else {
4179 return;
4180 };
4181 let dgs = if let Ok(mut s) = sec.lock() {
4182 s.note_remote_vendor(dp.sender_prefix, dp.sender_vendor);
4183 s.handle_remote_endpoints(dp);
4184 match dp.data.identity_token.as_ref() {
4185 Some(token) => s
4186 .begin_handshake_with(dp.sender_prefix, dp.data.guid.to_bytes(), token)
4187 .unwrap_or_default(),
4188 None => Vec::new(),
4189 }
4190 } else {
4191 Vec::new()
4192 };
4193 for dg in dgs {
4194 send_discovery_datagram(self, &dg.targets, &dg.bytes);
4195 }
4196 }
4197
4198 /// FU2 S3: in-process SPDP **participant** discovery. This was the real
4199 /// gap — `inproc_inject_publication`/`_subscription` only inject
4200 /// SEDP endpoints, the SPDP participant level (identity_token +
4201 /// `begin_handshake_with`) ran EXCLUSIVELY over the multicast path
4202 /// that is flaky on the codepit LXC. This hook, on activation of the
4203 /// security builtins, exchanges the participant DPs (with token) **bidirectionally** with
4204 /// all same-process+domain peers and kicks off the auth handshakes
4205 /// — deterministically, without a single multicast beacon.
4206 #[cfg(feature = "security")]
4207 fn inproc_announce_participant(self: &Arc<Self>) {
4208 let self_dp = self.self_as_discovered_participant();
4209 let peers: Vec<Arc<DcpsRuntime>> =
4210 crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
4211 .into_iter()
4212 .filter(|rt| rt.guid_prefix != self.guid_prefix)
4213 .collect();
4214 for peer in peers {
4215 // self → peer: the peer discovers US + triggers its handshake.
4216 let _ = peer
4217 .discovered
4218 .lock()
4219 .map(|mut c| c.insert(self_dp.clone()));
4220 peer.inproc_drive_security_handshake(&self_dp);
4221 // peer → self: WE discover the peer + trigger our handshake.
4222 let peer_dp = peer.self_as_discovered_participant();
4223 let _ = self
4224 .discovered
4225 .lock()
4226 .map(|mut c| c.insert(peer_dp.clone()));
4227 self.inproc_drive_security_handshake(&peer_dp);
4228 }
4229 }
4230
4231 /// C1 multicast-free discovery: sends a (possibly already security-
4232 /// transformed) SPDP beacon additionally to all configured
4233 /// unicast initial peers. No-op without peers → pure multicast behavior,
4234 /// no additional syscalls by default.
4235 fn send_spdp_to_initial_peers(&self, bytes: &[u8]) {
4236 for peer in &self.config.initial_peers {
4237 let _ = self.spdp_mc_tx.send(peer, bytes);
4238 }
4239 }
4240
4241 /// FU2 S3: sends an SPDP beacon IMMEDIATELY via multicast, instead of waiting
4242 /// for the next periodic `spdp_period` tick. Critical for the
4243 /// cross-process secured handshake: `DcpsRuntime::start` starts the
4244 /// beacon sender, whose first beacon (token-LESS) goes out BEFORE
4245 /// `enable_security_builtins_with_auth` sets the `identity_token` on the beacon.
4246 /// If a peer latches this token-less first beacon, it calls
4247 /// `begin_handshake_with` with `token=None` → no-op → the handshake NEVER
4248 /// starts. An immediate re-announce after setting the token ensures
4249 /// that the first token-carrying beacon goes out promptly.
4250 #[cfg(feature = "security")]
4251 fn announce_spdp_now(&self) {
4252 let mc_target = Locator {
4253 kind: LocatorKind::UdpV4,
4254 port: u32::from(
4255 u16::try_from(spdp_multicast_port(self.domain_id as u32)).unwrap_or(7400),
4256 ),
4257 address: {
4258 let mut a = [0u8; 16];
4259 a[12..].copy_from_slice(&self.config.spdp_multicast_group.octets());
4260 a
4261 },
4262 };
4263 if let Ok(mut beacon) = self.spdp_beacon.lock() {
4264 if let Ok(datagram) = beacon.serialize() {
4265 if let Some(secured) = secure_outbound_bytes(self, &datagram) {
4266 let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4267 // C1 multicast-free discovery: on the immediate announce too, to
4268 // the configured initial peers (ZERODDS_PEERS).
4269 self.send_spdp_to_initial_peers(&secured);
4270 // Directed unicast fan-out to already-discovered peers:
4271 // covers the order in which we discover a peer
4272 // BEFORE our security builtins (token) are active — then the
4273 // directed response in handle_spdp_datagram skipped tokenless;
4274 // announce_spdp_now() (called by enable() after the token set)
4275 // catches up with the tokened beacon promptly + LXC-multicast-
4276 // independently. Otherwise the peer waits until spdp_period.
4277 for loc in wlp_unicast_targets(&self.discovered_participants()) {
4278 let _ = self.spdp_unicast.send(&loc, &secured);
4279 }
4280 }
4281 }
4282 // FastDDS interop: additionally announce on the reliable secure SPDP
4283 // writer (0xff0101c2), so FastDDS sees our full secured
4284 // participant data over its expected channel.
4285 if self.config.enable_secure_spdp {
4286 if let Ok(datagram) = beacon.serialize_secure() {
4287 let protected = protect_secure_spdp(self, &datagram).unwrap_or(datagram);
4288 if let Some(secured) = secure_outbound_bytes(self, &protected) {
4289 let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4290 }
4291 }
4292 }
4293 }
4294 }
4295
4296 /// FU2 cross-vendor: `EndpointSecurityInfo` (PID_ENDPOINT_SECURITY_INFO,
4297 /// 0x1004) for user endpoints, derived from the governance
4298 /// `data_protection`. Foreign vendors (cyclone/FastDDS) reject, with
4299 /// `data_protection=ENCRYPT`, a user endpoint WITHOUT this PID as
4300 /// non-secure ("Non secure remote ... not allowed by security").
4301 /// `None` without an active security gate (plain).
4302 #[cfg(feature = "security")]
4303 fn user_endpoint_security_info(
4304 &self,
4305 ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4306 let gate = self.config.security.as_ref()?;
4307 let meta = gate.metadata_protection().ok()?;
4308 let data = gate.data_protection().ok()?;
4309 let disc = gate.topic_discovery_protected().unwrap_or(false);
4310 let liv = gate
4311 .liveliness_protection()
4312 .map(|l| l != ProtectionLevel::None)
4313 .unwrap_or(false);
4314 let rdp = gate.topic_read_protected().unwrap_or(false);
4315 let wrp = gate.topic_write_protected().unwrap_or(false);
4316 Some(compute_user_endpoint_attrs(meta, data, disc, liv, rdp, wrp))
4317 }
4318
4319 #[cfg(not(feature = "security"))]
4320 fn user_endpoint_security_info(
4321 &self,
4322 ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4323 None
4324 }
4325
4326 /// Registers a local user writer. The caller gets the
4327 /// writer `EntityId`; for sends via `write_user_sample(eid, ...)`.
4328 ///
4329 /// In the runtime there is **still no** automatic SEDP announce +
4330 /// matching — that comes in B4b. Currently `register_user_writer`
4331 /// is just the wiring.
4332 ///
4333 /// # Errors
4334 /// `PreconditionNotMet` if the registry mutex is poisoned.
4335 pub fn register_user_writer(&self, cfg: UserWriterConfig) -> Result<EntityId> {
4336 // Default: WithKey. Backward-compat for all test callers.
4337 self.register_user_writer_kind(cfg, true)
4338 }
4339
4340 /// Like [`register_user_writer`] but with an explicit NoKey/WithKey
4341 /// flag. Cross-vendor interop needs it: if the IDL type has no
4342 /// `@key`, the writer MUST set `is_keyed=false`, otherwise
4343 /// a remote reader rejects the DATA submessage due to an
4344 /// entityKind mismatch (Spec §9.3.1.2 table 9.1: 0x02=WithKey
4345 /// vs 0x03=NoKey).
4346 pub fn register_user_writer_kind(
4347 &self,
4348 cfg: UserWriterConfig,
4349 is_keyed: bool,
4350 ) -> Result<EntityId> {
4351 let now = self.start_instant.elapsed();
4352 let key = self.next_entity_key();
4353 let eid = if is_keyed {
4354 EntityId::user_writer_with_key(key)
4355 } else {
4356 EntityId::user_writer_no_key(key)
4357 };
4358 let writer = ReliableWriter::new(ReliableWriterConfig {
4359 guid: Guid::new(self.guid_prefix, eid),
4360 vendor_id: VendorId::ZERODDS,
4361 reader_proxies: Vec::new(),
4362 max_samples: 1024,
4363 history_kind: HistoryKind::KeepLast { depth: 32 },
4364 heartbeat_period: DEFAULT_HEARTBEAT_PERIOD,
4365 // Ethernet-safe default; the value is raised at the reader match
4366 // if all readers are same-host (see the
4367 // set_fragmentation call after add_reader_proxy).
4368 fragment_size: DEFAULT_FRAGMENT_SIZE,
4369 mtu: DEFAULT_MTU,
4370 });
4371 let mut pub_data = build_publication_data(
4372 self.guid_prefix,
4373 eid,
4374 &cfg,
4375 &self.config.data_representation_offer,
4376 self.user_announce_locator,
4377 );
4378 // FU2 cross-vendor: EndpointSecurityInfo from the governance
4379 // data_protection — otherwise cyclone/FastDDS reject the user endpoint
4380 // with data_protection=ENCRYPT as non-secure.
4381 pub_data.security_info = self.user_endpoint_security_info();
4382 self.user_writers
4383 .write()
4384 .map_err(|_| DdsError::PreconditionNotMet {
4385 reason: "user_writers poisoned",
4386 })?
4387 .insert(
4388 eid,
4389 Arc::new(Mutex::new(UserWriterSlot {
4390 writer,
4391 topic_name: cfg.topic_name.clone(),
4392 type_name: cfg.type_name.clone(),
4393 reliable: cfg.reliable,
4394 durability: cfg.durability,
4395 deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4396 // Initial `None`: the deadline window starts only on the
4397 // first real write. Prevents false misses due to
4398 // slow entity setup (e.g. Linux CI container)
4399 // before the app does its first write(). On the
4400 // first write() `last_write = Some(now)` is set,
4401 // and from then the deadline counter ticks.
4402 last_write: None,
4403 offered_deadline_missed_count: 0,
4404 liveliness_lost_count: 0,
4405 last_liveliness_assert: Some(now),
4406 offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus::default(
4407 ),
4408 lifespan_nanos: qos_duration_to_nanos(cfg.lifespan.duration),
4409 sample_insert_times: alloc::collections::VecDeque::new(),
4410 liveliness_kind: cfg.liveliness.kind,
4411 liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4412 ownership: cfg.ownership,
4413 ownership_strength: cfg.ownership_strength,
4414 partition: cfg.partition.clone(),
4415 #[cfg(feature = "security")]
4416 reader_protection: BTreeMap::new(),
4417 #[cfg(feature = "security")]
4418 locator_to_peer: BTreeMap::new(),
4419 type_identifier: cfg.type_identifier.clone(),
4420 data_rep_offer_override: cfg.data_representation_offer.clone(),
4421 // Default FINAL: irrelevant for XCDR1 (default offer)
4422 // (final==appendable==CDR_LE), correct for XCDR2 for
4423 // @final types. Appendable/mutable types set this later via
4424 // set_user_writer_wire_extensibility.
4425 wire_extensibility: zerodds_types::qos::ExtensibilityForRepr::Final,
4426 big_endian_override: false,
4427 durability_backend: None,
4428 backend_primed: false,
4429 history_depth: DEFAULT_INTRA_HISTORY_DEPTH,
4430 retained: alloc::collections::VecDeque::new(),
4431 intra_replayed_readers: alloc::collections::BTreeSet::new(),
4432 })),
4433 );
4434 // FIRST match locally, THEN announce — symmetric to
4435 // register_user_reader_kind. Avoids a peer-side match
4436 // triggered by our announce_publication
4437 // starting a data flow to us before we have wired the
4438 // ReaderProxies.
4439 self.match_local_writer_against_cache(eid);
4440 let _ = self.announce_publication(&pub_data);
4441 // Intra-runtime routing: scan local readers for a match on
4442 // (topic, type). Applies to bridge daemons with writer+reader in
4443 // the same runtime (WS/MQTT/CoAP/AMQP bridges). Without this
4444 // route the local reader gets no samples from the local
4445 // writer — the `inproc` fastpath explicitly skips self, UDP loopback
4446 // is not guaranteed, and SEDP match paths go via
4447 // the discovered cache, which does not contain self.
4448 self.recompute_intra_runtime_routes();
4449 // FU2 F-ECHO-WRITE: a user writer created AFTER handshake completion
4450 // (e.g. the event-driven echo writer in the responder/pong) must send its
4451 // per-endpoint datawriter_crypto_tokens IMMEDIATELY to the already-
4452 // authenticated peers — not only on the next tick. Otherwise
4453 // cyclone's reader stays in "waiting for approval by security" beyond
4454 // its match deadline (the event-driven pong may not tick
4455 // in time) → flaky sub=0. Idempotent via endpoint_tokens_sent dedup.
4456 #[cfg(feature = "security")]
4457 self.flush_late_endpoint_tokens();
4458 // Observability event.
4459 self.config.observability.record(
4460 &zerodds_foundation::observability::Event::new(
4461 zerodds_foundation::observability::Level::Info,
4462 zerodds_foundation::observability::Component::Dcps,
4463 "user_writer.created",
4464 )
4465 .with_attr("topic", cfg.topic_name.as_str())
4466 .with_attr("type", cfg.type_name.as_str())
4467 .with_attr("reliable", if cfg.reliable { "true" } else { "false" }),
4468 );
4469 Ok(eid)
4470 }
4471
4472 /// FU2 F-ECHO-WRITE: sends pending per-endpoint crypto tokens IMMEDIATELY to all
4473 /// already-authenticated peers. For user endpoints created AFTER handshake
4474 /// completion (event-driven echo writer in the responder): their token
4475 /// must go out before cyclone's reader match deadline expires — the periodic
4476 /// tick (or a VolatileSecure recv) is otherwise possibly too late. Idempotent
4477 /// via `endpoint_tokens_sent` dedup (double-send with the tick excluded).
4478 #[cfg(feature = "security")]
4479 fn flush_late_endpoint_tokens(&self) {
4480 let Some(stack) = self.security_builtin_snapshot() else {
4481 return;
4482 };
4483 let Ok(mut s) = stack.lock() else {
4484 return;
4485 };
4486 let now = self.start_instant.elapsed();
4487 let peers: alloc::vec::Vec<GuidPrefix> = self
4488 .config
4489 .security
4490 .as_ref()
4491 .map(|g| {
4492 g.authenticated_peer_prefixes()
4493 .into_iter()
4494 .map(GuidPrefix::from_bytes)
4495 .collect()
4496 })
4497 .unwrap_or_default();
4498 for prefix in peers {
4499 let already = self
4500 .endpoint_tokens_sent
4501 .read()
4502 .map(|set| set.clone())
4503 .unwrap_or_default();
4504 let pending =
4505 pending_endpoint_tokens(prepare_endpoint_crypto_tokens(self, prefix), &already);
4506 for ep_msg in pending {
4507 let key = endpoint_token_key(&ep_msg);
4508 let dgs = protect_volatile_outbound(
4509 self,
4510 prefix,
4511 s.volatile_writer
4512 .write_with_heartbeat(&ep_msg, now)
4513 .unwrap_or_default(),
4514 );
4515 for dg in dgs {
4516 for t in dg.targets.iter() {
4517 let _ = self.spdp_unicast.send(t, &dg.bytes);
4518 }
4519 }
4520 if let Ok(mut set) = self.endpoint_tokens_sent.write() {
4521 set.insert(key);
4522 }
4523 }
4524 // Periodic re-announce retrigger: as soon as the user writer/reader
4525 // is announced (announced_pubs/subs not empty), this catches up the
4526 // SEDP initially dropped under rtps_/discovery_protection to this
4527 // (now tokened) peer. Once per peer (dedup in the method).
4528 self.re_announce_sedp_to_peer(prefix);
4529 }
4530 }
4531
4532 /// Spec §2.2.3.5 — registers a durability-service backend on
4533 /// a writer already registered via [`register_user_writer`].
4534 /// With Durability=Transient/Persistent the backend is replayed into the
4535 /// HistoryCache on the first late-joiner match in
4536 /// `wire_writer_to_remote_reader`, so the reader gets all samples —
4537 /// including those no longer in the writer cache due to history eviction
4538 /// or those that have survived a writer restart.
4539 pub fn attach_durability_backend(
4540 &self,
4541 eid: EntityId,
4542 backend: alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>,
4543 ) -> Result<()> {
4544 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4545 what: "attach_durability_backend: unknown writer entity id",
4546 })?;
4547 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4548 reason: "user_writer slot poisoned",
4549 })?;
4550 slot.durability_backend = Some(backend);
4551 slot.backend_primed = false;
4552 Ok(())
4553 }
4554
4555 /// Sets the type extensibility of a writer (FINAL/APPENDABLE/
4556 /// MUTABLE). Affects exclusively the encapsulation header
4557 /// of the user payload (see [`user_payload_encap`]) — relevant for
4558 /// XCDR2 wire, where @appendable requires a `D_CDR2_LE` and @mutable a
4559 /// `PL_CDR2_LE` header. The codegen/FFI calls this after
4560 /// `register_user_writer*` when the type is not @final.
4561 /// Does NOT change the SEDP announce offer list.
4562 ///
4563 /// # Errors
4564 /// `BadParameter` on an unknown EntityId, `PreconditionNotMet` on a
4565 /// poisoned slot mutex.
4566 pub fn set_user_writer_wire_extensibility(
4567 &self,
4568 eid: EntityId,
4569 ext: zerodds_types::qos::ExtensibilityForRepr,
4570 ) -> Result<()> {
4571 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4572 what: "set_user_writer_wire_extensibility: unknown writer entity id",
4573 })?;
4574 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4575 reason: "user_writer slot poisoned",
4576 })?;
4577 slot.wire_extensibility = ext;
4578 Ok(())
4579 }
4580
4581 /// Registers a local user reader. Returns the reader EntityId
4582 /// and an `mpsc::Receiver` through which DataReader handles
4583 /// consume incoming samples.
4584 ///
4585 /// # Errors
4586 /// `PreconditionNotMet` if the registry mutex is poisoned.
4587 /// Registers a user reader. Returns the EntityId and an
4588 /// `mpsc::Receiver<UserSample>` — alive samples deliver payload,
4589 /// lifecycle markers carry key hash + ChangeKind.
4590 pub fn register_user_reader(
4591 &self,
4592 cfg: UserReaderConfig,
4593 ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4594 // Default: WithKey. Backward-compat for all test callers.
4595 self.register_user_reader_kind(cfg, true)
4596 }
4597
4598 /// Like [`register_user_reader`] but with an explicit NoKey/WithKey
4599 /// flag. Symmetric to [`register_user_writer_kind`] — the reader kind
4600 /// must match the writer kind.
4601 pub fn register_user_reader_kind(
4602 &self,
4603 cfg: UserReaderConfig,
4604 is_keyed: bool,
4605 ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4606 let now = self.start_instant.elapsed();
4607 let key = self.next_entity_key();
4608 let eid = if is_keyed {
4609 EntityId::user_reader_with_key(key)
4610 } else {
4611 EntityId::user_reader_no_key(key)
4612 };
4613 let reader = ReliableReader::new(ReliableReaderConfig {
4614 guid: Guid::new(self.guid_prefix, eid),
4615 vendor_id: VendorId::ZERODDS,
4616 writer_proxies: Vec::new(),
4617 max_samples_per_proxy: 256,
4618 // D.5e: 0ms = synchronous ACK response (Cyclone parity).
4619 // Previously 200ms = pre-1.0 default without spec justification.
4620 heartbeat_response_delay:
4621 zerodds_rtps::reliable_reader::DEFAULT_HEARTBEAT_RESPONSE_DELAY,
4622 // C3: ROS-realistic reassembly cap (PointCloud2/Image),
4623 // instead of the conservative rtps 1-MiB default.
4624 assembler_caps: AssemblerCaps {
4625 max_sample_bytes: self.config.max_reassembly_sample_bytes,
4626 ..AssemblerCaps::default()
4627 },
4628 });
4629 // BEST_EFFORT readers must not block delivery on a leading sequence-number
4630 // gap (RTPS §8.4.12.1) — they don't NACK to repair it, so waiting would
4631 // deadlock against e.g. an OpenDDS/RTI writer whose first sample to us is
4632 // mid-stream. Reliable readers keep strict in-order delivery.
4633 let mut reader = reader;
4634 reader.set_best_effort(!cfg.reliable);
4635 let (tx, rx) = mpsc::channel();
4636 // A DataReader announces every representation it can decode (XCDR2 +
4637 // XCDR1), not just the writer-preferred one — see `reader_accept_repr`.
4638 let reader_repr = reader_accept_repr(&self.config.data_representation_offer);
4639 let mut sub_data = build_subscription_data(
4640 self.guid_prefix,
4641 eid,
4642 &cfg,
4643 &reader_repr,
4644 self.user_announce_locator,
4645 );
4646 // FU2 cross-vendor: EndpointSecurityInfo from the governance (see writer).
4647 sub_data.security_info = self.user_endpoint_security_info();
4648 self.user_readers
4649 .write()
4650 .map_err(|_| DdsError::PreconditionNotMet {
4651 reason: "user_readers poisoned",
4652 })?
4653 .insert(
4654 eid,
4655 Arc::new(Mutex::new(UserReaderSlot {
4656 reader,
4657 topic_name: cfg.topic_name.clone(),
4658 type_name: cfg.type_name.clone(),
4659 sample_tx: tx,
4660 async_waker: Arc::new(std::sync::Mutex::new(None)),
4661 listener: None,
4662 durability: cfg.durability,
4663 deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4664 // Start time as reference (see register_user_writer).
4665 last_sample_received: Some(now),
4666 requested_deadline_missed_count: 0,
4667 requested_incompatible_qos:
4668 crate::status::RequestedIncompatibleQosStatus::default(),
4669 sample_lost_count: 0,
4670 sample_rejected: crate::status::SampleRejectedStatus::default(),
4671 samples_delivered_count: 0,
4672 liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4673 liveliness_kind: cfg.liveliness.kind,
4674 liveliness_alive_count: 0,
4675 liveliness_not_alive_count: 0,
4676 // Optimistic init: we see the writer via SEDP,
4677 // until the lease expires it counts as alive.
4678 liveliness_alive: true,
4679 liveliness_alive_writers: alloc::collections::BTreeSet::new(),
4680 ownership: cfg.ownership,
4681 partition: cfg.partition.clone(),
4682 writer_strengths: alloc::collections::BTreeMap::new(),
4683 type_identifier: cfg.type_identifier.clone(),
4684 type_consistency: cfg.type_consistency,
4685 // A2 — TIME_BASED_FILTER off by default; the C-FFI/rmw path
4686 // arms it via `set_user_reader_time_based_filter`.
4687 tbf_min_separation_nanos: 0,
4688 tbf_last_delivered: alloc::collections::BTreeMap::new(),
4689 })),
4690 );
4691 // FIRST match locally (create the writer proxy on the reader),
4692 // THEN announce. Otherwise our announce_subscription triggers a
4693 // backend replay at the peer via the in-process fastpath
4694 // (Spec §2.2.3.5), which injects DATA into *our* reader
4695 // before we have wired the matching WriterProxies — the
4696 // samples are then discarded as unknown-source
4697 // (tests `{transient,persistent}_late_joiner_receives_backend_replay`).
4698 self.match_local_reader_against_cache(eid);
4699 let _ = self.announce_subscription(&sub_data);
4700 // Intra-runtime routing: see `register_user_writer_kind`.
4701 self.recompute_intra_runtime_routes();
4702 // Observability event.
4703 self.config.observability.record(
4704 &zerodds_foundation::observability::Event::new(
4705 zerodds_foundation::observability::Level::Info,
4706 zerodds_foundation::observability::Component::Dcps,
4707 "user_reader.created",
4708 )
4709 .with_attr("topic", cfg.topic_name.as_str())
4710 .with_attr("type", cfg.type_name.as_str()),
4711 );
4712 Ok((eid, rx))
4713 }
4714
4715 /// Rebuilds the same-runtime writer→reader routing table.
4716 /// Called in `register_user_writer_kind` and `register_user_reader_kind`
4717 /// after every endpoint create. Per local writer it collects
4718 /// all local readers that have exactly the same `topic_name`
4719 /// and `type_name`. The lookup in the write hot path
4720 /// (`write_user_sample_borrowed`) is read-locked and cheap
4721 /// (BTreeMap lookup → Vec clone). On endpoint removal (TODO: not
4722 /// yet hooked everywhere) this would be called too.
4723 fn recompute_intra_runtime_routes(&self) {
4724 let writer_snap = self.writer_slots_snapshot();
4725 let reader_snap = self.reader_slots_snapshot();
4726 let mut new_map: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
4727 // QR-cluster (b): writers whose route gained a new reader and which are
4728 // TransientLocal must replay their retained history to those readers.
4729 // (writer_eid, reader_eid) pairs collected here, replayed after the
4730 // routing lock is released.
4731 let mut replay_targets: Vec<(EntityId, EntityId)> = Vec::new();
4732 for (writer_eid, w_arc) in writer_snap {
4733 let (w_topic, w_type, w_partition, w_transient_local) = match w_arc.lock() {
4734 Ok(s) => (
4735 s.topic_name.clone(),
4736 s.type_name.clone(),
4737 s.partition.clone(),
4738 !matches!(s.durability, zerodds_qos::DurabilityKind::Volatile),
4739 ),
4740 Err(_) => continue,
4741 };
4742 let mut readers: Vec<EntityId> = Vec::new();
4743 for (reader_eid, r_arc) in &reader_snap {
4744 let matches = match r_arc.lock() {
4745 Ok(s) => {
4746 s.topic_name == w_topic
4747 && s.type_name == w_type
4748 // QR-cluster (c): PARTITION gates the same-runtime
4749 // match exactly as on the wire (DDS 1.4 §2.2.3.13).
4750 && partitions_overlap(&w_partition, &s.partition)
4751 }
4752 Err(_) => false,
4753 };
4754 if matches {
4755 readers.push(*reader_eid);
4756 // Schedule a TransientLocal replay if this writer has not yet
4757 // replayed to this reader.
4758 if w_transient_local {
4759 let already = w_arc
4760 .lock()
4761 .map(|s| s.intra_replayed_readers.contains(reader_eid))
4762 .unwrap_or(true);
4763 if !already {
4764 replay_targets.push((writer_eid, *reader_eid));
4765 }
4766 }
4767 }
4768 }
4769 if !readers.is_empty() {
4770 new_map.insert(writer_eid, readers);
4771 }
4772 }
4773 // Perform the TransientLocal retained-sample replay to each new reader
4774 // (DDS 1.4 §2.2.3.4 late-joiner delivery). Done outside the routing
4775 // lock; the per-writer slot lock guards `retained` + the
4776 // already-replayed dedup set.
4777 for (writer_eid, reader_eid) in replay_targets {
4778 self.intra_runtime_replay_retained(writer_eid, reader_eid);
4779 }
4780 let changed = match self.intra_runtime_routes.write() {
4781 Ok(mut g) => {
4782 let changed = *g != new_map;
4783 *g = new_map;
4784 changed
4785 }
4786 Err(_) => false,
4787 };
4788 // A new/changed intra-runtime route is a same-participant
4789 // match → wake the `wait_for_matched_{subscription,publication}` waiter
4790 // (the matched count now includes these routes).
4791 if changed {
4792 self.match_event.1.notify_all();
4793 }
4794 }
4795
4796 /// QR-cluster (b): replays a TransientLocal writer's retained samples
4797 /// (DDS 1.4 §2.2.3.4) to a single late-joining intra-runtime reader. Each
4798 /// retained Alive entry is delivered as `UserSample::Alive`; each terminal
4799 /// lifecycle entry as `UserSample::Lifecycle`, so the reader's instance
4800 /// state reflects the most recent NOT_ALIVE_DISPOSED / NOT_ALIVE_NO_WRITERS.
4801 /// Idempotent via the per-writer `intra_replayed_readers` set.
4802 fn intra_runtime_replay_retained(&self, writer_eid: EntityId, reader_eid: EntityId) {
4803 // Snapshot retained under the writer lock, mark replayed, then release.
4804 let samples: Vec<RetainedSample> = {
4805 let Some(w_arc) = self.writer_slot(writer_eid) else {
4806 return;
4807 };
4808 let Ok(mut w) = w_arc.lock() else {
4809 return;
4810 };
4811 if !w.intra_replayed_readers.insert(reader_eid) {
4812 return; // already replayed to this reader
4813 }
4814 w.retained.iter().cloned().collect()
4815 };
4816 if samples.is_empty() {
4817 return;
4818 }
4819 let Some(r_arc) = self.reader_slot(reader_eid) else {
4820 return;
4821 };
4822 let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
4823 let (listener, waker, sender) = {
4824 let Ok(r) = r_arc.lock() else {
4825 return;
4826 };
4827 (
4828 r.listener.clone(),
4829 Arc::clone(&r.async_waker),
4830 r.sample_tx.clone(),
4831 )
4832 };
4833 for s in samples {
4834 match s.lifecycle {
4835 None => {
4836 if let Some(l) = &listener {
4837 // Durability replay is little-endian (the store does not
4838 // retain the original byte order) → big_endian = 0.
4839 l(&s.payload, s.representation, 0);
4840 } else {
4841 let sample = UserSample::Alive {
4842 payload: crate::sample_bytes::SampleBytes::from_vec(s.payload.clone()),
4843 writer_guid,
4844 writer_strength: s.strength,
4845 representation: s.representation,
4846 // Durability replay: the store does not yet retain
4847 // the original byte order (ZeroDDS-internal samples
4848 // are little-endian); a big-endian peer's durable
4849 // sample would replay LE. Tracked as a durability
4850 // followup, not part of the live RTPS BE path.
4851 big_endian: false,
4852 // Durability replay: original source timestamp not
4853 // retained in the store today → reception order.
4854 source_timestamp: None,
4855 };
4856 let _ = sender.send(sample);
4857 wake_async_waker(&waker);
4858 }
4859 }
4860 Some(kind) => {
4861 // Lifecycle markers always go to the MPSC channel (the
4862 // alive-only listener does not carry instance state).
4863 let _ = sender.send(UserSample::Lifecycle {
4864 key_hash: s.key_hash,
4865 kind,
4866 });
4867 wake_async_waker(&waker);
4868 }
4869 }
4870 }
4871 }
4872
4873 /// QR-cluster (d): delivers a lifecycle marker (dispose / unregister) to all
4874 /// matched intra-runtime readers (DDS 1.4 §2.2.2.4.2.10 / §2.2.2.4.2.7) so
4875 /// their instance state becomes NOT_ALIVE_DISPOSED / NOT_ALIVE_NO_WRITERS,
4876 /// and records it in the writer's retained buffer so a later late joiner
4877 /// also observes the terminal state. The wire path is handled separately by
4878 /// [`Self::write_user_lifecycle`].
4879 fn intra_runtime_dispatch_lifecycle(
4880 &self,
4881 writer_eid: EntityId,
4882 key_hash: [u8; 16],
4883 kind: zerodds_rtps::history_cache::ChangeKind,
4884 ) {
4885 // Record in retained (terminal marker for the key) so future late
4886 // joiners observe the NOT_ALIVE state.
4887 if let Some(w_arc) = self.writer_slot(writer_eid) {
4888 if let Ok(mut w) = w_arc.lock() {
4889 if !matches!(w.durability, zerodds_qos::DurabilityKind::Volatile) {
4890 // Replace any prior terminal marker for this key; keep the
4891 // retained alive samples (the reader saw them already, but a
4892 // brand-new late joiner needs both the data and the state).
4893 w.retained
4894 .retain(|s| !(s.lifecycle.is_some() && s.key_hash == key_hash));
4895 w.retained.push_back(RetainedSample {
4896 key_hash,
4897 payload: Vec::new(),
4898 representation: 0,
4899 strength: 0,
4900 lifecycle: Some(kind),
4901 });
4902 }
4903 }
4904 }
4905 let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
4906 Ok(g) => match g.get(&writer_eid) {
4907 Some(v) => v.clone(),
4908 None => return,
4909 },
4910 Err(_) => return,
4911 };
4912 for reader_eid in routes {
4913 let Some(slot_arc) = self.reader_slot(reader_eid) else {
4914 continue;
4915 };
4916 let (waker, sender) = {
4917 let Ok(slot) = slot_arc.lock() else {
4918 continue;
4919 };
4920 (Arc::clone(&slot.async_waker), slot.sample_tx.clone())
4921 };
4922 let _ = sender.send(UserSample::Lifecycle { key_hash, kind });
4923 wake_async_waker(&waker);
4924 }
4925 }
4926
4927 /// Same-runtime direct dispatch: pushes the just-written
4928 /// sample directly into the `sample_tx` channel of all local readers
4929 /// on the same topic+type. Avoids an RTPS wire roundtrip + UDP
4930 /// loopback for the bridge-daemon case (writer+reader in the same
4931 /// `DcpsRuntime`). Called by the write hot path after the normal
4932 /// wire dispatch.
4933 fn intra_runtime_dispatch_alive(
4934 &self,
4935 writer_eid: EntityId,
4936 payload: &[u8],
4937 writer_strength: i32,
4938 // XCDR version tag of the writer's effective offer (`0` = XCDR1,
4939 // `1` = XCDR2), matching `encap_representation`'s convention on the
4940 // wire-receive path. On the wire path the reader recovers this from
4941 // byte[1] of the encap header; here the intra-runtime payload carries
4942 // no encap header, so the writer's actual representation must be
4943 // threaded through explicitly (Bug R4 — previously hardcoded `0`,
4944 // losing the XCDR version on the same-runtime loopback path).
4945 representation: u8,
4946 ) {
4947 let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
4948 Ok(g) => match g.get(&writer_eid) {
4949 Some(v) => v.clone(),
4950 None => return,
4951 },
4952 Err(_) => return,
4953 };
4954 if routes.is_empty() {
4955 return;
4956 }
4957 let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
4958 // QR-cluster (e): LIVELINESS AUTOMATIC auto-renew. A delivered sample
4959 // proves the matched writer is alive (DDS 1.4 §2.2.3.11). For AUTOMATIC
4960 // kind the infrastructure renews liveliness implicitly, so each
4961 // intra-runtime delivery marks the writer alive at the reader.
4962 let writer_liveliness_automatic = self
4963 .writer_slot(writer_eid)
4964 .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_kind))
4965 .map(|k| matches!(k, zerodds_qos::LivelinessKind::Automatic))
4966 .unwrap_or(false);
4967 for reader_eid in routes {
4968 let Some(slot_arc) = self.reader_slot(reader_eid) else {
4969 continue;
4970 };
4971 // Hold the slot lock only for the listener/sender clone, dispatch
4972 // outside (symmetric to the data-receive path above, which
4973 // preserves exactly the same order in the DATA arm).
4974 let listener;
4975 let waker;
4976 let sender;
4977 {
4978 let Ok(mut slot) = slot_arc.lock() else {
4979 continue;
4980 };
4981 // Liveliness renew: bump alive_count once per writer-alive
4982 // transition (the reader sees this writer become alive).
4983 if writer_liveliness_automatic {
4984 let newly_alive = slot.liveliness_alive_writers.insert(writer_guid);
4985 if newly_alive {
4986 slot.liveliness_alive = true;
4987 slot.liveliness_alive_count = slot.liveliness_alive_count.saturating_add(1);
4988 }
4989 }
4990 listener = slot.listener.clone();
4991 waker = Arc::clone(&slot.async_waker);
4992 sender = slot.sample_tx.clone();
4993 }
4994 // Listener and MPSC are exclusive (see the data-arm comment):
4995 // if a listener is set, the sample only goes to it;
4996 // otherwise to the MPSC receiver.
4997 if let Some(l) = listener {
4998 // The listener signature is `(payload, representation, big_endian)`.
4999 // Intra-runtime: no encap header, so carry the writer's
5000 // actual representation tag (Bug R4); same-process delivery is
5001 // always native little-endian → big_endian = 0.
5002 l(payload, representation, 0);
5003 } else {
5004 let sample = UserSample::Alive {
5005 payload: crate::sample_bytes::SampleBytes::from_vec(payload.to_vec()),
5006 writer_guid,
5007 writer_strength,
5008 representation,
5009 // Intra-runtime same-process delivery always produces the
5010 // native little-endian wire.
5011 big_endian: false,
5012 // Intra-runtime same-process delivery bypasses the INFO_TS
5013 // wire path → reception order.
5014 source_timestamp: None,
5015 };
5016 let _ = sender.send(sample);
5017 wake_async_waker(&waker);
5018 }
5019 }
5020 }
5021
5022 /// On registration / SEDP event: for a local writer `eid`
5023 /// go through all subscriptions known in the cache; on a topic+type
5024 /// match add a `ReaderProxy` to the local ReliableWriter.
5025 fn match_local_writer_against_cache(&self, eid: EntityId) {
5026 let (topic, type_name) = {
5027 let Some(arc) = self.writer_slot(eid) else {
5028 return;
5029 };
5030 let Ok(s) = arc.lock() else {
5031 return;
5032 };
5033 (s.topic_name.clone(), s.type_name.clone())
5034 };
5035 let (matches, conflict): (Vec<_>, bool) = {
5036 let sedp = match self.sedp.lock() {
5037 Ok(s) => s,
5038 Err(_) => return,
5039 };
5040 let matches = sedp
5041 .cache()
5042 .match_subscriptions(&topic, &type_name)
5043 .map(|s| s.data.clone())
5044 .collect();
5045 let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
5046 (matches, conflict)
5047 };
5048 if conflict {
5049 self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
5050 }
5051 for sub in matches {
5052 self.wire_writer_to_remote_reader(eid, &sub);
5053 }
5054 }
5055
5056 fn match_local_reader_against_cache(&self, eid: EntityId) {
5057 let (topic, type_name) = {
5058 let Some(arc) = self.reader_slot(eid) else {
5059 return;
5060 };
5061 let Ok(s) = arc.lock() else {
5062 return;
5063 };
5064 (s.topic_name.clone(), s.type_name.clone())
5065 };
5066 let (matches, conflict): (Vec<_>, bool) = {
5067 let sedp = match self.sedp.lock() {
5068 Ok(s) => s,
5069 Err(_) => return,
5070 };
5071 let matches = sedp
5072 .cache()
5073 .match_publications(&topic, &type_name)
5074 .map(|p| p.data.clone())
5075 .collect();
5076 let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
5077 (matches, conflict)
5078 };
5079 if conflict {
5080 self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
5081 }
5082 for pubd in matches {
5083 self.wire_reader_to_remote_writer(eid, &pubd);
5084 }
5085 }
5086
5087 fn wire_writer_to_remote_reader(
5088 &self,
5089 writer_eid: EntityId,
5090 sub: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
5091 ) {
5092 // §2.2.2.2.1.16: an ignored subscription must not be MATCHED (symmetric
5093 // to the publication gate in `wire_reader_to_remote_writer`). The
5094 // Durability-Service ignores its own ingest reader here so the replay
5095 // writer never delivers back to it (echo loop).
5096 if let Some(filter) = self.ignore_filter_snapshot() {
5097 let sub_h = crate::instance_handle::InstanceHandle::from_guid(sub.key);
5098 let part_h = crate::instance_handle::InstanceHandle::from_guid(sub.participant_key);
5099 if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
5100 return;
5101 }
5102 }
5103 let locators =
5104 endpoint_or_default_locators(&sub.unicast_locators, sub.key.prefix, &self.discovered);
5105 if locators.is_empty() {
5106 return;
5107 }
5108 // Backend replay datagrams (Spec §2.2.3.5). Sent after
5109 // the slot-lock release, so the send path does not run under
5110 // the slot mutex.
5111 let mut replay_dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram> = Vec::new();
5112 if let Some(slot_arc) = self.writer_slot(writer_eid) {
5113 if let Ok(mut slot) = slot_arc.lock() {
5114 let slot = &mut *slot;
5115 // Idempotency gate: if a ReaderProxy already exists for this
5116 // remote reader, the match has already run
5117 // once. A re-wire (e.g. when the SEDP announcement
5118 // arrives at the writer both via the in-process fastpath and via UDP)
5119 // would REPLACE the proxy via
5120 // `add_reader_proxy` — and thereby reset
5121 // `highest_acked_sn`/`highest_sent_sn`.
5122 // The next tick then emits an invalid HEARTBEAT
5123 // with `first_sn > last_sn` (cache_min=N, highest_acked+1=N+1),
5124 // the reader interprets this as "everything before first_sn is
5125 // lost" and advances `delivered_up_to` past not-yet-
5126 // delivered backend replay samples (tests
5127 // `{transient,persistent}_late_joiner_receives_backend_replay`
5128 // — 3% flake without the gate).
5129 if slot
5130 .writer
5131 .reader_proxies()
5132 .iter()
5133 .any(|p| p.remote_reader_guid == sub.key)
5134 {
5135 return;
5136 }
5137 // --- QoS-Compatibility ---
5138 // Spec OMG DDS 1.4 §2.2.3.6: Writer offered >= Reader requested.
5139 //
5140 // Per reject, bump the responsible policy ID in
5141 // `offered_incompatible_qos.policies`, so the
5142 // DataWriter listener is triggered via `dispatch_offered_incompatible_qos`.
5143 // We track the *first* faulty
5144 // policy as `last_policy_id` (Spec §2.2.4.1: most-recent).
5145 use crate::psm_constants::qos_policy_id as qid;
5146 use crate::status::bump_policy_count;
5147 // C2 "loud instead of silent": an incompatible QoS match is
5148 // not only kept as a pollable status (Spec §2.2.4.1),
5149 // but logged loudly IMMEDIATELY. The central ROS-DDS
5150 // pain point is that QoS mismatches are silently discarded
5151 // (e.g. Cyclone's `DDS_INVALID_QOS_POLICY_ID` without a
5152 // log) — exactly that made the ROS-2 entityKind diagnosis so
5153 // expensive. The reject names the topic, remote reader and
5154 // the exact policy.
5155 let obs = self.config.observability.clone();
5156 let topic_for_log = slot.topic_name.clone();
5157 let remote_for_log = alloc::format!("{:?}", sub.key);
5158 let bump = |slot: &mut UserWriterSlot, pid: u32| {
5159 slot.offered_incompatible_qos.total_count =
5160 slot.offered_incompatible_qos.total_count.saturating_add(1);
5161 slot.offered_incompatible_qos.last_policy_id = pid;
5162 bump_policy_count(&mut slot.offered_incompatible_qos.policies, pid);
5163 obs.record(
5164 &zerodds_foundation::observability::Event::new(
5165 zerodds_foundation::observability::Level::Warn,
5166 zerodds_foundation::observability::Component::Dcps,
5167 "qos.incompatible.offered",
5168 )
5169 .with_attr("topic", topic_for_log.as_str())
5170 .with_attr("remote_reader", remote_for_log.as_str())
5171 .with_attr("policy", qos_policy_id_name(pid)),
5172 );
5173 };
5174
5175 // Durability rank: Volatile < TransientLocal < Transient <
5176 // Persistent. The writer may offer more than the reader requests.
5177 if (slot.durability as u8) < (sub.durability as u8) {
5178 bump(slot, qid::DURABILITY);
5179 return;
5180 }
5181 // Deadline: writer period <= reader period (the writer promises
5182 // to write faster than the reader expects).
5183 if !deadline_compat(
5184 slot.deadline_nanos,
5185 qos_duration_to_nanos(sub.deadline.period),
5186 ) {
5187 bump(slot, qid::DEADLINE);
5188 return;
5189 }
5190 // Liveliness-Kind: Automatic < ManualByParticipant < ManualByTopic.
5191 // Writer-Kind >= Reader-Kind. Lease: writer.lease <= reader.lease.
5192 if (slot.liveliness_kind as u8) < (sub.liveliness.kind as u8) {
5193 bump(slot, qid::LIVELINESS);
5194 return;
5195 }
5196 if !deadline_compat(
5197 slot.liveliness_lease_nanos,
5198 qos_duration_to_nanos(sub.liveliness.lease_duration),
5199 ) {
5200 bump(slot, qid::LIVELINESS);
5201 return;
5202 }
5203 // Ownership: both must be equal (Spec §2.2.3.6 Table:
5204 // no "compatible" case except exactly equal).
5205 if slot.ownership != sub.ownership {
5206 bump(slot, qid::OWNERSHIP);
5207 return;
5208 }
5209 // Partition: at least one common partition — or
5210 // both empty (default partition "").
5211 if !partitions_overlap(&slot.partition, &sub.partition) {
5212 bump(slot, qid::PARTITION);
5213 return;
5214 }
5215 // F-TYPES-3 XTypes-1.3 §7.6.3.7 symmetric writer-side check.
5216 // If both sides carry a TypeIdentifier (≠ None),
5217 // we check compatibility. The reader's TCE policy is not
5218 // directly available here; we take the default TCE
5219 // (AllowTypeCoercion without PreventWidening) — the reader-
5220 // side check in `wire_reader_to_remote_writer` validates
5221 // with the real reader TCE.
5222 if slot.type_identifier != zerodds_types::TypeIdentifier::None
5223 && sub.type_identifier != zerodds_types::TypeIdentifier::None
5224 // Equal TypeIdentifiers are by definition the same type
5225 // (XTypes 1.3 §7.2.4.1 identity). This is the typed-endpoint
5226 // case: writer + reader of the same generated type carry the
5227 // same (possibly complete) TypeIdentifier, whose TypeObject
5228 // is NOT in this fresh registry. Without this short-circuit a
5229 // complete-hash type-id would fail the assignability lookup
5230 // (Bug QT). Skip the registry-backed structural check when the
5231 // ids are identical.
5232 && slot.type_identifier != sub.type_identifier
5233 {
5234 let registry = zerodds_types::resolve::TypeRegistry::new();
5235 let tce = zerodds_types::qos::TypeConsistencyEnforcement::default();
5236 let matcher = zerodds_types::type_matcher::TypeMatcher::new(&tce);
5237 if !matcher
5238 .match_types(&slot.type_identifier, &sub.type_identifier, ®istry)
5239 .is_match()
5240 {
5241 bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5242 return;
5243 }
5244 }
5245
5246 let mut proxy = zerodds_rtps::reader_proxy::ReaderProxy::new(
5247 sub.key,
5248 locators.clone(),
5249 Vec::new(),
5250 slot.reliable,
5251 );
5252 // D.5g — Per-Peer DataRepresentation negotiation
5253 // (XTypes 1.3 §7.6.3.1.2). Writer-offered = Per-Writer-
5254 // Override (slot.data_rep_offer_override) ODER Runtime-
5255 // Default. Reader-accepted = sub.data_representation
5256 // (spec default `[XCDR1]` if empty). Match mode from
5257 // RuntimeConfig.
5258 {
5259 use zerodds_rtps::publication_data::data_representation as dr;
5260 let writer_offered: Vec<i16> = slot
5261 .data_rep_offer_override
5262 .clone()
5263 .unwrap_or_else(|| self.config.data_representation_offer.clone());
5264 let mode = self.config.data_rep_match_mode;
5265 if let Some(negotiated) =
5266 dr::negotiate(&writer_offered, &sub.data_representation, mode)
5267 {
5268 proxy.set_negotiated_data_representation(negotiated);
5269 } else {
5270 // No overlap → SEDP match spec violation.
5271 // We add the proxy anyway for best-effort
5272 // compat; the wire-format default stays XCDR2.
5273 // A spec-strict caller should reject the match.
5274 }
5275 }
5276 // Spec §2.2.3.4 Tab. 16: cache replay suppression. For
5277 // Volatile the reader must not see any late-joiner history
5278 // → skip up to `cache.max_sn`. For Transient/Persistent
5279 // the backend is authoritative — we deliver the history
5280 // via the backend replay path with NEW SNs; the
5281 // writer's own cache (especially gappy under KeepLast
5282 // eviction) must not serve the reader twice.
5283 // TransientLocal is the only tier where the
5284 // writer cache is the real history anchor.
5285 if !matches!(slot.durability, zerodds_qos::DurabilityKind::TransientLocal) {
5286 if let Some(max) = slot.writer.cache().max_sn() {
5287 proxy.skip_samples_up_to(max);
5288 }
5289 }
5290 // Spec §2.2.3.5 — Durability=Transient/Persistent:
5291 // on the first late-joiner match, re-inject the backend samples
5292 // into the HistoryCache. The existing
5293 // reliable-reader path then delivers them via DATA +
5294 // heartbeat/AckNack. Idempotent via the
5295 // `backend_primed` flag.
5296 let backend_writes: Vec<Vec<u8>> = if !slot.backend_primed
5297 && (slot.durability == zerodds_qos::DurabilityKind::Transient
5298 || slot.durability == zerodds_qos::DurabilityKind::Persistent)
5299 {
5300 slot.durability_backend
5301 .as_ref()
5302 .and_then(|b| b.replay_for_topic(&slot.topic_name).ok())
5303 .unwrap_or_default()
5304 .into_iter()
5305 .map(|s| s.payload)
5306 .collect()
5307 } else {
5308 Vec::new()
5309 };
5310 slot.writer.add_reader_proxy(proxy);
5311 // Path-MTU-aware fragmentation: if ALL matched
5312 // readers run on the same host, traffic goes via
5313 // loopback (MTU 65536) — then one datagram per sample
5314 // instead of N 1344-B fragments (halves the 8-kB roundtrip
5315 // latency). As soon as a reader is remote, it stays
5316 // Ethernet-safe at DEFAULT_FRAGMENT_SIZE, so no
5317 // oversized datagram gets IP-fragmented on the 1500-byte
5318 // path.
5319 let all_same_host = slot
5320 .writer
5321 .reader_proxies()
5322 .iter()
5323 .all(|p| self.guid_prefix.is_same_host(p.remote_reader_guid.prefix));
5324 if all_same_host {
5325 slot.writer
5326 .set_fragmentation(LOOPBACK_FRAGMENT_SIZE, LOOPBACK_MTU);
5327 } else {
5328 slot.writer
5329 .set_fragmentation(DEFAULT_FRAGMENT_SIZE, DEFAULT_MTU);
5330 }
5331 // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): if the
5332 // remote reader runs on the same host (matching
5333 // GuidPrefix host-id, wave 4a), register the pair in the
5334 // SameHostTracker. Wave 4b.3 (feature `same-host-shm`):
5335 // additionally try to set up a PosixShmTransport owner
5336 // segment — on success `mark_bound(Owner)`,
5337 // otherwise `mark_failed` and UDP fallback.
5338 if self.guid_prefix.is_same_host(sub.key.prefix) {
5339 let local_writer_guid =
5340 zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
5341 self.same_host.register_pending(local_writer_guid, sub.key);
5342 #[cfg(feature = "same-host-shm")]
5343 {
5344 match crate::same_host_shm::open_owner_segment(
5345 self.guid_prefix,
5346 local_writer_guid,
5347 sub.key,
5348 ) {
5349 Ok(t) => self.same_host.mark_bound(
5350 local_writer_guid,
5351 sub.key,
5352 t,
5353 crate::same_host::Role::Owner,
5354 ),
5355 Err(reason) => {
5356 self.same_host
5357 .mark_failed(local_writer_guid, sub.key, reason)
5358 }
5359 }
5360 }
5361 }
5362 // Inject the backend replay into the HistoryCache (within
5363 // the slot lock). Important: with `KeepLast(N)` and a small N
5364 // the cache would immediately evict every replay sample
5365 // again — the subsequent writer tick then sees
5366 // SN=4,5 as "not in cache" and sends GAPs to the
5367 // reader, which marks our replay samples as irrelevant.
5368 // Solution: temporarily expand the cache to `KeepAll` with
5369 // a sufficient cap, for the duration of the
5370 // burst, then restore the user QoS.
5371 // Backend samples are in **raw** format (that is how
5372 // `DataWriter::write` in publisher.rs stores them) — before the
5373 // writer.write we must prepend the USER_PAYLOAD_ENCAP framing,
5374 // so the reader recognizes the stream value spec-conformantly
5375 // (see `validate_user_encap_offset`).
5376 let now_replay = self.start_instant.elapsed();
5377 if !backend_writes.is_empty() {
5378 // Same encap header as in the live-write path
5379 // (offer `first` + extensibility), so replay samples
5380 // declare the same wire encoding.
5381 let replay_encap = {
5382 let offer_first = slot
5383 .data_rep_offer_override
5384 .as_ref()
5385 .and_then(|v| v.first().copied())
5386 .or_else(|| self.config.data_representation_offer.first().copied())
5387 .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
5388 user_payload_encap(
5389 offer_first,
5390 slot.wire_extensibility,
5391 slot.big_endian_override,
5392 )
5393 };
5394 let original_kind = slot.writer.cache().kind();
5395 let original_max = slot.writer.cache().max_samples();
5396 let burst_max = original_max
5397 .saturating_add(backend_writes.len())
5398 .max(backend_writes.len() + 16);
5399 slot.writer.set_cache_kind_and_max(
5400 zerodds_rtps::history_cache::HistoryKind::KeepAll,
5401 burst_max,
5402 );
5403 for raw_payload in &backend_writes {
5404 let mut framed = Vec::with_capacity(replay_encap.len() + raw_payload.len());
5405 framed.extend_from_slice(&replay_encap);
5406 framed.extend_from_slice(raw_payload);
5407 if let Ok(out) = slot.writer.write_with_heartbeat(&framed, now_replay) {
5408 replay_dgs.extend(out);
5409 }
5410 }
5411 slot.writer
5412 .set_cache_kind_and_max(original_kind, original_max);
5413 slot.backend_primed = true;
5414 }
5415 // D.5e Phase-1: wake `wait_for_matched_subscription`-waiters.
5416 self.match_event.1.notify_all();
5417
5418 // Security: derive the per-reader protection level from
5419 // security_info and build the locator lookup map,
5420 // so the writer tick can serialize per target
5421 // individually.
5422 #[cfg(feature = "security")]
5423 {
5424 let peer_key = sub.key.prefix.0;
5425 // Set the per-reader level ONLY for an EXPLICITLY announced
5426 // `PID_ENDPOINT_SECURITY_INFO`. If it is missing (OpenDDS does not
5427 // send it — it relies on the domain governance), NO
5428 // None override: then the governance `data_protection` FLOOR
5429 // applies in `secure_outbound_for_target`. An authenticated peer
5430 // in a data_protection=ENCRYPT domain expects the encrypted
5431 // payload; a None override would leak plaintext (cyclone/
5432 // FastDDS announce security_info → unchanged).
5433 if let Some(info) = sub.security_info.as_ref() {
5434 let level = EndpointProtection::from_info(Some(info)).level;
5435 slot.reader_protection.insert(peer_key, level);
5436 }
5437 for loc in &locators {
5438 slot.locator_to_peer.insert(*loc, peer_key);
5439 }
5440 }
5441 }
5442 }
5443 // Send the backend replay datagrams (Spec §2.2.3.5). The slot mutex
5444 // is released here; the send path mirrors the pattern from
5445 // `write_user_sample` — including the in-process fastpath for
5446 // same-process peers (otherwise UDP loopback loss under load can
5447 // swallow the Transient/Persistent replay samples).
5448 let inproc_peers: Vec<Arc<DcpsRuntime>> = {
5449 let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
5450 all.into_iter()
5451 .filter(|rt| rt.guid_prefix != self.guid_prefix)
5452 .collect()
5453 };
5454 let now_send = self.start_instant.elapsed();
5455 for dg in &replay_dgs {
5456 for t in dg.targets.iter() {
5457 if is_routable_user_locator(t) {
5458 let _ = self.user_unicast.send(t, &dg.bytes);
5459 }
5460 }
5461 for peer in &inproc_peers {
5462 handle_user_datagram(peer, &dg.bytes, now_send);
5463 }
5464 }
5465 // Emit the match event outside the slot mutex.
5466 self.config.observability.record(
5467 &zerodds_foundation::observability::Event::new(
5468 zerodds_foundation::observability::Level::Info,
5469 zerodds_foundation::observability::Component::Discovery,
5470 "writer.matched_remote_reader",
5471 )
5472 .with_attr("writer_eid", alloc::format!("{writer_eid:?}")),
5473 );
5474 }
5475
5476 fn wire_reader_to_remote_writer(
5477 &self,
5478 reader_eid: EntityId,
5479 pubd: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
5480 ) {
5481 // §2.2.2.2.1.17: an ignored publication must not be MATCHED, not merely
5482 // hidden from the DCPSPublication builtin reader. The Durability-Service
5483 // relies on this to avoid ingesting its own replay writer (echo loop).
5484 if let Some(filter) = self.ignore_filter_snapshot() {
5485 let pub_h = crate::instance_handle::InstanceHandle::from_guid(pubd.key);
5486 let part_h = crate::instance_handle::InstanceHandle::from_guid(pubd.participant_key);
5487 if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
5488 return;
5489 }
5490 }
5491 let locators =
5492 endpoint_or_default_locators(&pubd.unicast_locators, pubd.key.prefix, &self.discovered);
5493 if locators.is_empty() {
5494 return;
5495 }
5496 if let Some(slot_arc) = self.reader_slot(reader_eid) {
5497 if let Ok(mut slot) = slot_arc.lock() {
5498 let slot = &mut *slot;
5499 // Idempotency gate (symmetric to
5500 // `wire_writer_to_remote_reader`): if a WriterProxy already
5501 // exists for this remote writer, the
5502 // match has already run. A re-wire via UDP SEDP after
5503 // an in-process pull would REPLACE via `add_writer_proxy` —
5504 // resetting `delivered_up_to`/`received` and
5505 // losing already-buffered/delivered samples.
5506 if slot
5507 .reader
5508 .writer_proxies()
5509 .iter()
5510 .any(|s| s.proxy.remote_writer_guid == pubd.key)
5511 {
5512 return;
5513 }
5514 // Per-policy bump for requested_incompatible_qos.
5515 use crate::psm_constants::qos_policy_id as qid;
5516 use crate::status::bump_policy_count;
5517 // C2 "loud instead of silent" (symmetric to the writer side):
5518 // an incompatible QoS match is logged loudly immediately.
5519 let obs = self.config.observability.clone();
5520 let topic_for_log = slot.topic_name.clone();
5521 let remote_for_log = alloc::format!("{:?}", pubd.key);
5522 let bump = |slot: &mut UserReaderSlot, pid: u32| {
5523 slot.requested_incompatible_qos.total_count = slot
5524 .requested_incompatible_qos
5525 .total_count
5526 .saturating_add(1);
5527 slot.requested_incompatible_qos.last_policy_id = pid;
5528 bump_policy_count(&mut slot.requested_incompatible_qos.policies, pid);
5529 obs.record(
5530 &zerodds_foundation::observability::Event::new(
5531 zerodds_foundation::observability::Level::Warn,
5532 zerodds_foundation::observability::Component::Dcps,
5533 "qos.incompatible.requested",
5534 )
5535 .with_attr("topic", topic_for_log.as_str())
5536 .with_attr("remote_writer", remote_for_log.as_str())
5537 .with_attr("policy", qos_policy_id_name(pid)),
5538 );
5539 };
5540
5541 // See wire_writer... — symmetric, the writer is now remote.
5542 if (pubd.durability as u8) < (slot.durability as u8) {
5543 bump(slot, qid::DURABILITY);
5544 return;
5545 }
5546 if !deadline_compat(
5547 qos_duration_to_nanos(pubd.deadline.period),
5548 slot.deadline_nanos,
5549 ) {
5550 bump(slot, qid::DEADLINE);
5551 return;
5552 }
5553 if (pubd.liveliness.kind as u8) < (slot.liveliness_kind as u8) {
5554 bump(slot, qid::LIVELINESS);
5555 return;
5556 }
5557 if !deadline_compat(
5558 qos_duration_to_nanos(pubd.liveliness.lease_duration),
5559 slot.liveliness_lease_nanos,
5560 ) {
5561 bump(slot, qid::LIVELINESS);
5562 return;
5563 }
5564 if pubd.ownership != slot.ownership {
5565 bump(slot, qid::OWNERSHIP);
5566 return;
5567 }
5568 if !partitions_overlap(&pubd.partition, &slot.partition) {
5569 bump(slot, qid::PARTITION);
5570 return;
5571 }
5572
5573 // F-TYPES-3 XTypes-1.3 §7.6.3.7 TypeConsistencyEnforcement.
5574 // If both sides carry a TypeIdentifier (≠ None),
5575 // we check compatibility via the TypeMatcher. Otherwise
5576 // the match falls back to a pure type_name comparison (default path).
5577 if slot.type_identifier != zerodds_types::TypeIdentifier::None
5578 && pubd.type_identifier != zerodds_types::TypeIdentifier::None
5579 // Equal TypeIdentifiers ⇒ same type (XTypes 1.3 §7.2.4.1).
5580 // The typed-endpoint case carries a complete TypeIdentifier
5581 // whose TypeObject is not in this fresh registry; identity
5582 // is decisive without a structural lookup (Bug QT).
5583 && pubd.type_identifier != slot.type_identifier
5584 {
5585 let registry = zerodds_types::resolve::TypeRegistry::new();
5586 let matcher =
5587 zerodds_types::type_matcher::TypeMatcher::new(&slot.type_consistency);
5588 if !matcher
5589 .match_types(&pubd.type_identifier, &slot.type_identifier, ®istry)
5590 .is_match()
5591 {
5592 bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5593 return;
5594 }
5595 }
5596
5597 slot.reader
5598 .add_writer_proxy(zerodds_rtps::writer_proxy::WriterProxy::new(
5599 pubd.key,
5600 locators,
5601 Vec::new(),
5602 true,
5603 ));
5604 // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): reader
5605 // side of the same-host match. If the remote writer runs on
5606 // the same host, register the pair AND
5607 // attach synchronously to the SHM segment.
5608 //
5609 // Idempotent: thanks to the `PosixShmTransport::open` refactor
5610 // (transport-shm bug fix 2026-05-19) it does not matter whether the
5611 // writer hook (open_owner) or the reader hook
5612 // (open_consumer) runs first — whoever comes first
5613 // creates the segment, whoever later attaches. Real-life
5614 // DDS has no guaranteed SEDP match order.
5615 if self.guid_prefix.is_same_host(pubd.key.prefix) {
5616 let local_reader_guid =
5617 zerodds_rtps::wire_types::Guid::new(self.guid_prefix, reader_eid);
5618 self.same_host.register_pending(pubd.key, local_reader_guid);
5619 #[cfg(feature = "same-host-shm")]
5620 {
5621 match crate::same_host_shm::open_consumer_segment(
5622 self.guid_prefix,
5623 pubd.key,
5624 local_reader_guid,
5625 ) {
5626 Ok(t) => self.same_host.mark_bound(
5627 pubd.key,
5628 local_reader_guid,
5629 t,
5630 crate::same_host::Role::Consumer,
5631 ),
5632 Err(reason) => {
5633 self.same_host
5634 .mark_failed(pubd.key, local_reader_guid, reason)
5635 }
5636 }
5637 }
5638 }
5639 // D.5e Phase-1: wake `wait_for_matched_publication`-waiters.
5640 self.match_event.1.notify_all();
5641
5642 // §2.2.3.23 exclusive-ownership resolver cache:
5643 // remember the writer `ownership_strength` from discovery, so
5644 // `delivered_to_user_sample` can pack the value into every
5645 // sample.
5646 slot.writer_strengths
5647 .insert(pubd.key.to_bytes(), pubd.ownership_strength);
5648 }
5649 }
5650 }
5651
5652 /// Writes a sample to a registered user writer and
5653 /// sends the generated datagrams.
5654 ///
5655 /// The payload is prefixed with the RTPS serialized-payload header
5656 /// (encapsulation scheme) before it goes into the DATA
5657 /// submessage. OMG RTPS 2.5 §9.4.2.13 requires exactly these
5658 /// 4 bytes at the start of every serialized user payload —
5659 /// see [`USER_PAYLOAD_ENCAP`] (`CDR_LE` / XCDR1).
5660 /// Without this header Cyclone/Fast-DDS readers refuse to
5661 /// deliver the sample (they parse the first 4 bytes as
5662 /// encapsulation kind + options and drop unknown-scheme).
5663 ///
5664 /// # Errors
5665 /// - `BadParameter` if the EntityId has no registered writer.
5666 /// - `WireError` on an encoding error.
5667 pub fn write_user_sample(&self, eid: EntityId, payload: Vec<u8>) -> Result<()> {
5668 // Vec-ownership API. The spec contract is unchanged. We delegate to
5669 // the borrowed variant; this saves a heap-allocation hop when
5670 // the caller already has a `&[u8]` (e.g. the C-FFI loan API).
5671 self.write_user_sample_borrowed(eid, &payload)
5672 }
5673
5674 /// Sets the per-writer data-representation override for a user writer. The
5675 /// next `write_user_sample*` derives its encapsulation header from this
5676 /// override's first element instead of the runtime default — so a
5677 /// representation-faithful re-publisher (e.g. the durability service
5678 /// replaying foreign-vendor XCDR1 bytes) can declare the encap that matches
5679 /// the body it holds. `None` clears the override (back to the runtime
5680 /// default). Idempotent + cheap; safe to call before every write.
5681 ///
5682 /// # Errors
5683 /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5684 /// poisoned slot lock.
5685 pub fn set_user_writer_data_rep_override(
5686 &self,
5687 eid: EntityId,
5688 offer: Option<Vec<i16>>,
5689 ) -> Result<()> {
5690 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5691 what: "unknown writer entity id",
5692 })?;
5693 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5694 reason: "user_writer slot poisoned",
5695 })?;
5696 slot.data_rep_offer_override = offer;
5697 Ok(())
5698 }
5699
5700 /// Forces the writer to emit the big-endian (`_BE`) encapsulation variant
5701 /// (RTPS 2.5 §10.5) instead of the little-endian default. Used by the
5702 /// durability service replay path: a big-endian peer's stored sample holds
5703 /// big-endian body bytes, so its replay must carry a matching BE encap
5704 /// header. `false` restores the canonical little-endian wire.
5705 ///
5706 /// # Errors
5707 /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5708 /// poisoned slot lock.
5709 pub fn set_user_writer_byte_order_override(
5710 &self,
5711 eid: EntityId,
5712 big_endian: bool,
5713 ) -> Result<()> {
5714 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5715 what: "unknown writer entity id",
5716 })?;
5717 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5718 reason: "user_writer slot poisoned",
5719 })?;
5720 slot.big_endian_override = big_endian;
5721 Ok(())
5722 }
5723
5724 /// Sets the HISTORY KeepLast depth (DDS 1.4 §2.2.3.18) for a user writer.
5725 /// This governs how many of the most-recent samples **per instance key**
5726 /// are retained for the same-runtime TransientLocal late-joiner replay path
5727 /// (`intra_runtime_dispatch_alive` retains, a new route replays). Pass
5728 /// `usize::MAX` for KeepAll. A binding maps its HistoryQosPolicy here.
5729 ///
5730 /// # Errors
5731 /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5732 /// poisoned slot lock.
5733 pub fn set_user_writer_history_depth(&self, eid: EntityId, depth: usize) -> Result<()> {
5734 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5735 what: "unknown writer entity id",
5736 })?;
5737 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5738 reason: "user_writer slot poisoned",
5739 })?;
5740 slot.history_depth = depth.max(1);
5741 // Re-enforce the new depth over the already-retained samples per key.
5742 let d = slot.history_depth;
5743 enforce_retained_depth(&mut slot.retained, d);
5744 Ok(())
5745 }
5746
5747 /// Reads the current TransientLocal retained-sample count for a user writer
5748 /// (test/introspection helper). `0` for an unknown writer.
5749 #[must_use]
5750 pub fn user_writer_retained_len(&self, eid: EntityId) -> usize {
5751 self.writer_slot(eid)
5752 .and_then(|arc| arc.lock().ok().map(|s| s.retained.len()))
5753 .unwrap_or(0)
5754 }
5755
5756 /// Writes a user sample from a borrowed byte slice.
5757 /// **Zero-copy path** for the loan API and SHM backend: avoids
5758 /// the Vec materialization when the caller holds a slot/stack buffer.
5759 ///
5760 /// Identical semantics to `write_user_sample`; it just takes no
5761 /// ownership of the buffer.
5762 ///
5763 /// # Errors
5764 /// As `write_user_sample`.
5765 pub fn write_user_sample_borrowed(&self, eid: EntityId, payload: &[u8]) -> Result<()> {
5766 self.write_user_sample_keyed(eid, payload, [0u8; 16])
5767 }
5768
5769 /// Like [`write_user_sample_borrowed`] but with an explicit 16-byte instance
5770 /// `key_hash` (DDS 1.4 §2.2.2.4.2 keyed topics). The key is used by the
5771 /// same-runtime TransientLocal retention path so KeepLast depth is enforced
5772 /// **per instance** and a late joiner replays the most-recent samples of
5773 /// every live instance (and any disposed/unregistered terminal marker).
5774 /// A binding that does not key its topic passes the all-zero key (one
5775 /// default instance), which is what `write_user_sample_borrowed` does.
5776 ///
5777 /// # Errors
5778 /// As [`write_user_sample_borrowed`].
5779 pub fn write_user_sample_keyed(
5780 &self,
5781 eid: EntityId,
5782 payload: &[u8],
5783 key_hash: [u8; 16],
5784 ) -> Result<()> {
5785 let _phase_guard = if phase_timing_enabled() {
5786 Some(PhaseTimer {
5787 start: std::time::Instant::now(),
5788 ns_acc: &PHASE_WRITE_USER_NS,
5789 calls_acc: &PHASE_WRITE_USER_CALLS,
5790 })
5791 } else {
5792 None
5793 };
5794 let pt_on = phase_timing_enabled();
5795 let pt_t0 = if pt_on {
5796 Some(std::time::Instant::now())
5797 } else {
5798 None
5799 };
5800 // Hot path: for small samples (<= 1.5 kB payload)
5801 // the encap framing is copied into a stack PoolBuffer —
5802 // zero heap touch in the framing step. Large samples fall
5803 // back to Vec.
5804 let now = self.start_instant.elapsed();
5805 let total = USER_PAYLOAD_ENCAP.len() + payload.len();
5806 let pt_t2_out: Option<std::time::Instant>;
5807 // XCDR version tag of the writer's effective offer (`0` = XCDR1,
5808 // `1` = XCDR2), set below from the same `offer_first` that drives the
5809 // wire encap header. Carried into the same-runtime loopback dispatch
5810 // so the intra-runtime reader sees the writer's real representation
5811 // (Bug R4) instead of an unconditional `0`.
5812 let intra_representation: u8;
5813 let out_datagrams = {
5814 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5815 what: "unknown writer entity id",
5816 })?;
5817 let pt_t1 = if pt_on {
5818 Some(std::time::Instant::now())
5819 } else {
5820 None
5821 };
5822 if let (Some(t0), Some(t1)) = (pt_t0, pt_t1) {
5823 PHASE_WRITE_SUB_NS[0].fetch_add(
5824 (t1 - t0).as_nanos() as u64,
5825 core::sync::atomic::Ordering::Relaxed,
5826 );
5827 }
5828 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5829 reason: "user_writer slot poisoned",
5830 })?;
5831 let pt_t2 = if pt_on {
5832 Some(std::time::Instant::now())
5833 } else {
5834 None
5835 };
5836 pt_t2_out = pt_t2;
5837 if let (Some(t1), Some(t2)) = (pt_t1, pt_t2) {
5838 PHASE_WRITE_SUB_NS[1].fetch_add(
5839 (t2 - t1).as_nanos() as u64,
5840 core::sync::atomic::Ordering::Relaxed,
5841 );
5842 }
5843 // Deadline timer: remember the last write for offered_deadline_missed.
5844 slot.last_write = Some(now);
5845 // Encap header from the effective offer `first` (per-writer
5846 // override else runtime default) + type extensibility. The app
5847 // encoder serializes exactly this wire format; the header must
5848 // declare it honestly (otherwise an XCDR2-only vendor
5849 // reader misparses). See `user_payload_encap`.
5850 let encap = {
5851 let offer_first = slot
5852 .data_rep_offer_override
5853 .as_ref()
5854 .and_then(|v| v.first().copied())
5855 .or_else(|| self.config.data_representation_offer.first().copied())
5856 .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
5857 // Map the negotiated i16 DataRepresentationId to the u8 XCDR
5858 // version tag used by `UserSample::Alive.representation` /
5859 // `encap_representation` (`1` = XCDR2, `0` = XCDR1). Mirrors
5860 // the wire path where the reader derives this from the encap
5861 // header byte[1].
5862 intra_representation =
5863 if offer_first == zerodds_rtps::publication_data::data_representation::XCDR2 {
5864 1
5865 } else {
5866 0
5867 };
5868 user_payload_encap(
5869 offer_first,
5870 slot.wire_extensibility,
5871 slot.big_endian_override,
5872 )
5873 };
5874 // Spec §2.2.3.5 backend filling happens in
5875 // `DataWriter::write` (publisher.rs) with the **raw** payload —
5876 // here only the HistoryCache filling + wire send.
5877 let dgs = if total <= SMALL_FRAME_CAP {
5878 write_user_sample_pooled(&mut slot.writer, payload, now, &encap)?
5879 } else {
5880 let mut framed = Vec::with_capacity(total);
5881 framed.extend_from_slice(&encap);
5882 framed.extend_from_slice(payload);
5883 // See write_user_sample_pooled: HB rate-limited via the
5884 // tick loop instead of per-write.
5885 let _ = now;
5886 slot.writer
5887 .write(&framed)
5888 .map_err(|_| DdsError::WireError {
5889 message: String::from("user writer encode"),
5890 })?
5891 };
5892 // Lifespan: remember the insert time of the just-written SN.
5893 if slot.lifespan_nanos != 0 {
5894 if let Some(sn) = slot.writer.cache().max_sn() {
5895 slot.sample_insert_times.push_back((sn, now));
5896 }
5897 }
5898 // QR-cluster (a)+(b): TRANSIENT_LOCAL same-runtime retention with
5899 // per-instance HISTORY KeepLast depth (DDS 1.4 §2.2.3.4 + §2.2.3.18).
5900 // A new sample for a key clears any prior terminal lifecycle marker
5901 // for that key (the instance is alive again) and is appended; the
5902 // depth is then re-enforced per key.
5903 if !matches!(slot.durability, zerodds_qos::DurabilityKind::Volatile) {
5904 slot.retained
5905 .retain(|s| !(s.lifecycle.is_some() && s.key_hash == key_hash));
5906 let strength = slot.ownership_strength;
5907 slot.retained.push_back(RetainedSample {
5908 key_hash,
5909 payload: payload.to_vec(),
5910 representation: intra_representation,
5911 strength,
5912 lifecycle: None,
5913 });
5914 let depth = slot.history_depth;
5915 enforce_retained_depth(&mut slot.retained, depth);
5916 }
5917 dgs
5918 };
5919 let pt_t3 = if pt_on {
5920 Some(std::time::Instant::now())
5921 } else {
5922 None
5923 };
5924 if let (Some(t2), Some(t3)) = (pt_t2_out, pt_t3) {
5925 PHASE_WRITE_SUB_NS[2].fetch_add(
5926 (t3 - t2).as_nanos() as u64,
5927 core::sync::atomic::Ordering::Relaxed,
5928 );
5929 }
5930 // Opt-4 (Spec `zerodds-zero-copy-1.0` §9): precompute the skip set
5931 // of UDP locators occupied by a bound same-host reader.
5932 // Readers on these locators get the sample via
5933 // SHM (`same_host_send_pass` below); a UDP send would be a duplicate.
5934 #[cfg(feature = "same-host-shm")]
5935 let same_host_skip_locators: Vec<Locator> = self.same_host_udp_skip_set(eid);
5936 // In-process fastpath (same-process+domain peers): snapshot the
5937 // peer runtimes ONCE per write, then feed each datagram directly into
5938 // their recv path — no UDP loopback, no reliable
5939 // recovery race. The receiver deduplicates by SequenceNumber,
5940 // a copy arriving additionally via UDP later is a
5941 // no-op. The wire path stays untouched for cross-process.
5942 //
5943 // Hot-path fast path: lock-free registry hint. In the typical
5944 // cross-process bench (ping in process A, pong in process B)
5945 // A's registry has only A — the `peers()` lock+Vec alloc would be
5946 // pure overhead per write. Skip when count ≤ 1.
5947 let inproc_peers: Vec<Arc<DcpsRuntime>> = if crate::inproc::registry_count_hint() <= 1 {
5948 Vec::new()
5949 } else {
5950 let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
5951 all.into_iter()
5952 .filter(|rt| rt.guid_prefix != self.guid_prefix)
5953 .collect()
5954 };
5955 for dg in out_datagrams {
5956 // FU2 S3: UDP per target with per-reader data_protection
5957 // (`secure_outbound_for_target` — heterogeneously correct: legacy readers
5958 // get plaintext, secure readers SRTPS; the governance
5959 // data_protection fallback applies for readers without explicit
5960 // SEDP security_info).
5961 for t in dg.targets.iter() {
5962 if is_routable_user_locator(t) {
5963 #[cfg(feature = "same-host-shm")]
5964 if same_host_skip_locators.iter().any(|s| s == t) {
5965 continue;
5966 }
5967 if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
5968 #[allow(clippy::print_stderr)]
5969 if let Err(e) = self.user_unicast.send(t, &secured) {
5970 if std::env::var("ZERODDS_TRACE_SEND_ERR")
5971 .map(|s| s == "1")
5972 .unwrap_or(false)
5973 {
5974 eprintln!("[TRACE] user_unicast.send({t:?}) failed: {e:?}");
5975 }
5976 }
5977 }
5978 }
5979 }
5980 // SHM + in-process fastpath: `secure_user_outbound` (uniform
5981 // governance data_protection level). The inproc peer runs through
5982 // its secured inbound path (decrypt or drop),
5983 // symmetric to the UDP recv — otherwise a non-
5984 // authenticated same-process peer could see encrypted data
5985 // unencrypted.
5986 if let Some(secured) = secure_user_outbound(self, &dg.bytes) {
5987 // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6):
5988 // parallel send via SHM to all bound-owner entries
5989 // for this writer. Opt-4 above filters their UDP
5990 // locators out beforehand, so nothing is sent twice.
5991 #[cfg(feature = "same-host-shm")]
5992 self.same_host_send_pass(eid, &secured);
5993 for peer in &inproc_peers {
5994 #[cfg(feature = "security")]
5995 {
5996 if let Some(clear) =
5997 secure_inbound_bytes(peer, &secured, &DEFAULT_INBOUND_IFACE)
5998 {
5999 handle_user_datagram(peer, &clear, now);
6000 }
6001 }
6002 #[cfg(not(feature = "security"))]
6003 handle_user_datagram(peer, &secured, now);
6004 }
6005 }
6006 }
6007 let pt_t4 = if pt_on {
6008 Some(std::time::Instant::now())
6009 } else {
6010 None
6011 };
6012 if let (Some(t3), Some(t4)) = (pt_t3, pt_t4) {
6013 PHASE_WRITE_SUB_NS[3].fetch_add(
6014 (t4 - t3).as_nanos() as u64,
6015 core::sync::atomic::Ordering::Relaxed,
6016 );
6017 }
6018 // Same-runtime writer→reader loopback: in parallel to the wire path
6019 // push directly into the `sample_tx` of all local readers on the same
6020 // topic+type. Bridge-daemon use case (writer+reader
6021 // in the same DcpsRuntime); without this hook intra-process
6022 // loopback would be completely dead, because `inproc_announce_*` skips self
6023 // and UDP multicast loopback is not guaranteed. Strength from
6024 // the writer slot.
6025 let writer_strength = self
6026 .writer_slot(eid)
6027 .and_then(|arc| arc.lock().ok().map(|s| s.ownership_strength))
6028 .unwrap_or(0);
6029 self.intra_runtime_dispatch_alive(eid, payload, writer_strength, intra_representation);
6030 // Embargo inspect tap at the DCPS layer (path-separated from the
6031 // production path). Only compiled when the `inspect` feature is
6032 // on. The topic name is fetched via a separate lookup, outside
6033 // the lock region so hooks do not run under the lock.
6034 #[cfg(feature = "inspect")]
6035 {
6036 self.dispatch_inspect_dcps_tap(eid, payload);
6037 }
6038 // D.5e Phase 3 — a freshly written sample makes a HEARTBEAT due: wake the
6039 // scheduler tick so it goes out immediately (no 5 ms tail), speeding the
6040 // reliable HB→ACKNACK handshake.
6041 self.raise_tick_wake();
6042 Ok(())
6043 }
6044
6045 /// Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) helper:
6046 /// sends `bytes` to all bound-owner entries of the [`SameHostTracker`]
6047 /// for this local writer (owner role).
6048 ///
6049 /// Called by the [`Self::write_user_sample`] hot path after the UDP send.
6050 /// Same-host readers thereby receive the sample frame
6051 /// via SHM **in addition** to the UDP path — the reader HistoryCache
6052 /// deduplicates by SequenceNumber.
6053 #[cfg(feature = "same-host-shm")]
6054 /// Opt-4 (Spec `zerodds-zero-copy-1.0` §9): locator skip set for
6055 /// the UDP send path. Returns all UDP default-unicast locators
6056 /// of the readers that have a bound same-host SHM pair with this
6057 /// writer — the hot-path caller filters these targets out of
6058 /// `dg.targets`, so the same readers are not served twice
6059 /// (UDP + SHM).
6060 #[cfg(feature = "same-host-shm")]
6061 fn same_host_udp_skip_set(&self, writer_eid: EntityId) -> Vec<Locator> {
6062 use crate::same_host::{Role, SameHostState};
6063 let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
6064 let mut skip: Vec<Locator> = Vec::new();
6065 let snapshot = self.same_host.snapshot();
6066 let discovered = self.discovered.clone();
6067 for (w, reader, state) in snapshot {
6068 if w != writer_guid {
6069 continue;
6070 }
6071 if !matches!(
6072 state,
6073 SameHostState::Bound {
6074 role: Role::Owner,
6075 ..
6076 }
6077 ) {
6078 continue;
6079 }
6080 // Reader prefix → default_unicast_locator from discovery.
6081 if let Ok(cache) = discovered.lock() {
6082 if let Some(p) = cache.get(&reader.prefix) {
6083 if let Some(loc) = p.data.default_unicast_locator {
6084 skip.push(loc);
6085 }
6086 }
6087 }
6088 }
6089 skip
6090 }
6091
6092 #[cfg(feature = "same-host-shm")]
6093 fn same_host_send_pass(&self, writer_eid: EntityId, bytes: &[u8]) {
6094 use crate::same_host::{Role, SameHostState};
6095 use zerodds_transport::Transport;
6096 use zerodds_transport_shm::PosixShmTransport;
6097
6098 let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
6099 let snapshot = self.same_host.snapshot();
6100 let total = snapshot.len();
6101 let mut matched = 0u32;
6102 let mut owners = 0u32;
6103 let mut sent = 0u32;
6104 for (w, _reader, state) in snapshot {
6105 if w != writer_guid {
6106 continue;
6107 }
6108 matched += 1;
6109 let SameHostState::Bound { transport, role } = state else {
6110 continue;
6111 };
6112 if !matches!(role, Role::Owner) {
6113 continue;
6114 }
6115 owners += 1;
6116 let Ok(t) = transport.downcast::<PosixShmTransport>() else {
6117 continue;
6118 };
6119 // ShmTransport is 1:1: send() validates `dest ==
6120 // peer_locator`. Owner.peer_locator points to the
6121 // consumer endpoint → that is our target.
6122 let target = t.peer_locator();
6123 if t.send(&target, bytes).is_ok() {
6124 sent += 1;
6125 }
6126 }
6127 let _ = (total, matched, owners, sent); // diag counter removed after the Bug-3 fix
6128 }
6129
6130 /// Inspect-endpoint tap dispatch for DCPS publish.
6131 /// Reads the topic name separately from the WriterSlot and passes
6132 /// a frame to the zerodds-inspect-endpoint tap registry.
6133 /// **Not** the production hot path: only when the `inspect` feature is on.
6134 #[cfg(feature = "inspect")]
6135 fn dispatch_inspect_dcps_tap(&self, eid: EntityId, payload: &[u8]) {
6136 let Some(slot_arc) = self.writer_slot(eid) else {
6137 return;
6138 };
6139 let topic = match slot_arc.lock() {
6140 Ok(slot) => slot.topic_name.clone(),
6141 Err(_) => return,
6142 };
6143 let ts_ns = std::time::SystemTime::now()
6144 .duration_since(std::time::UNIX_EPOCH)
6145 .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
6146 .unwrap_or(0);
6147 let mut corr: u64 = 0;
6148 for (i, byte) in eid.entity_key.iter().enumerate() {
6149 corr |= u64::from(*byte) << (i * 8);
6150 }
6151 corr |= u64::from(eid.entity_kind as u8) << 24;
6152 let frame = zerodds_inspect_endpoint::Frame::dcps(topic, ts_ns, corr, payload.to_vec());
6153 zerodds_inspect_endpoint::tap::dispatch(&frame);
6154 }
6155
6156 /// Sends a lifecycle marker (`dispose`/`unregister_instance`) to
6157 /// all matched readers. Spec §2.2.2.4.2.10/.7 + §9.6.3.9 PID_STATUS_INFO.
6158 /// `status_bits` is the OR combination of
6159 /// `zerodds_rtps::inline_qos::status_info::DISPOSED` and/or `UNREGISTERED`.
6160 ///
6161 /// # Errors
6162 /// - `BadParameter` if the EntityId has no registered writer.
6163 /// - `WireError` on an encode error.
6164 pub fn write_user_lifecycle(
6165 &self,
6166 eid: EntityId,
6167 key_hash: [u8; 16],
6168 status_bits: u32,
6169 ) -> Result<()> {
6170 let out_datagrams = {
6171 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
6172 what: "unknown writer entity id",
6173 })?;
6174 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
6175 reason: "user_writer slot poisoned",
6176 })?;
6177 slot.writer
6178 .write_lifecycle(key_hash, status_bits)
6179 .map_err(|_| DdsError::WireError {
6180 message: String::from("user writer lifecycle encode"),
6181 })?
6182 };
6183 for dg in out_datagrams {
6184 // FU2 S3: lifecycle DATA (dispose/unregister) per-target
6185 // data_protection-aware (heterogeneously correct like the immediate send).
6186 for t in dg.targets.iter() {
6187 if is_routable_user_locator(t) {
6188 if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
6189 let _ = self.user_unicast.send(t, &secured);
6190 }
6191 }
6192 }
6193 }
6194 // QR-cluster (d): also deliver the lifecycle marker to matched
6195 // same-runtime readers — the wire targets above never include
6196 // intra-runtime local readers (those go via the direct dispatch path).
6197 // Map the PID_STATUS_INFO bits to the HistoryCache ChangeKind.
6198 use zerodds_rtps::inline_qos::status_info;
6199 let disposed = status_bits & status_info::DISPOSED != 0;
6200 let unregistered = status_bits & status_info::UNREGISTERED != 0;
6201 let kind = match (disposed, unregistered) {
6202 (true, true) => zerodds_rtps::history_cache::ChangeKind::NotAliveDisposedUnregistered,
6203 (true, false) => zerodds_rtps::history_cache::ChangeKind::NotAliveDisposed,
6204 (false, true) => zerodds_rtps::history_cache::ChangeKind::NotAliveUnregistered,
6205 // No status bits set: nothing to deliver as a lifecycle marker.
6206 (false, false) => return Ok(()),
6207 };
6208 self.intra_runtime_dispatch_lifecycle(eid, key_hash, kind);
6209 Ok(())
6210 }
6211
6212 /// Generates a 3-byte entity key for new user endpoints.
6213 fn next_entity_key(&self) -> [u8; 3] {
6214 let n = self.entity_counter.fetch_add(1, Ordering::Relaxed);
6215 [(n >> 16) as u8, (n >> 8) as u8, n as u8]
6216 }
6217
6218 /// Snapshot of all currently known remote publications (topic
6219 /// name + type name + writer GUID).
6220 #[must_use]
6221 pub fn discovered_publications_count(&self) -> usize {
6222 self.sedp
6223 .lock()
6224 .map(|s| s.cache().publications_len())
6225 .unwrap_or(0)
6226 }
6227
6228 /// Snapshot of every publication on this domain as `(topic_name,
6229 /// type_name)` — raw DDS topic/type strings — for graph introspection
6230 /// (`rmw_get_topic_names_and_types`, `rmw_count_publishers`). Includes BOTH
6231 /// this participant's LOCAL user writers AND the remote publications from
6232 /// SEDP, so a node sees its own topics as well as its peers'.
6233 #[must_use]
6234 pub fn discovered_publication_topics(&self) -> Vec<(String, String)> {
6235 let mut out: Vec<(String, String)> = Vec::new();
6236 if let Ok(map) = self.user_writers.read() {
6237 for slot in map.values() {
6238 if let Ok(s) = slot.lock() {
6239 out.push((s.topic_name.clone(), s.type_name.clone()));
6240 }
6241 }
6242 }
6243 if let Ok(s) = self.sedp.lock() {
6244 out.extend(
6245 s.cache()
6246 .publications()
6247 .map(|p| (p.data.topic_name.clone(), p.data.type_name.clone())),
6248 );
6249 }
6250 out
6251 }
6252
6253 /// Snapshot of every subscription on this domain as `(topic_name,
6254 /// type_name)` (local user readers + remote SEDP). Counterpart to
6255 /// [`Self::discovered_publication_topics`].
6256 #[must_use]
6257 pub fn discovered_subscription_topics(&self) -> Vec<(String, String)> {
6258 let mut out: Vec<(String, String)> = Vec::new();
6259 if let Ok(map) = self.user_readers.read() {
6260 for slot in map.values() {
6261 if let Ok(s) = slot.lock() {
6262 out.push((s.topic_name.clone(), s.type_name.clone()));
6263 }
6264 }
6265 }
6266 if let Ok(s) = self.sedp.lock() {
6267 out.extend(
6268 s.cache()
6269 .subscriptions()
6270 .map(|s| (s.data.topic_name.clone(), s.data.type_name.clone())),
6271 );
6272 }
6273 out
6274 }
6275
6276 /// Snapshot of all currently known remote subscriptions.
6277 #[must_use]
6278 pub fn discovered_subscriptions_count(&self) -> usize {
6279 self.sedp
6280 .lock()
6281 .map(|s| s.cache().subscriptions_len())
6282 .unwrap_or(0)
6283 }
6284
6285 /// Per-endpoint snapshot of every publication on this domain (local user
6286 /// writers + remote SEDP), for ROS 2 `rmw_get_publishers_info_by_topic`.
6287 #[must_use]
6288 pub fn discovered_publication_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
6289 let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
6290 let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
6291 if let Ok(map) = self.user_writers.read() {
6292 for slot in map.values() {
6293 if let Ok(s) = slot.lock() {
6294 out.push(DiscoveredEndpointInfo {
6295 topic_name: s.topic_name.clone(),
6296 type_name: s.type_name.clone(),
6297 endpoint_guid: guid_to_16(s.writer.guid()),
6298 reliable: s.reliable,
6299 transient_local: !matches!(
6300 s.durability,
6301 zerodds_qos::DurabilityKind::Volatile
6302 ),
6303 deadline_seconds: secs(s.deadline_nanos),
6304 lifespan_seconds: secs(s.lifespan_nanos),
6305 liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
6306 });
6307 }
6308 }
6309 }
6310 if let Ok(s) = self.sedp.lock() {
6311 for p in s.cache().publications() {
6312 out.push(DiscoveredEndpointInfo {
6313 topic_name: p.data.topic_name.clone(),
6314 type_name: p.data.type_name.clone(),
6315 endpoint_guid: guid_to_16(p.data.key),
6316 reliable: matches!(
6317 p.data.reliability.kind,
6318 zerodds_qos::ReliabilityKind::Reliable
6319 ),
6320 transient_local: !matches!(
6321 p.data.durability,
6322 zerodds_qos::DurabilityKind::Volatile
6323 ),
6324 deadline_seconds: p.data.deadline.period.seconds,
6325 lifespan_seconds: p.data.lifespan.duration.seconds,
6326 liveliness_lease_seconds: p.data.liveliness.lease_duration.seconds,
6327 });
6328 }
6329 }
6330 out
6331 }
6332
6333 /// Counterpart to [`Self::discovered_publication_endpoints`] for
6334 /// subscriptions (`rmw_get_subscriptions_info_by_topic`).
6335 #[must_use]
6336 pub fn discovered_subscription_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
6337 let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
6338 let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
6339 if let Ok(map) = self.user_readers.read() {
6340 for slot in map.values() {
6341 if let Ok(s) = slot.lock() {
6342 out.push(DiscoveredEndpointInfo {
6343 topic_name: s.topic_name.clone(),
6344 type_name: s.type_name.clone(),
6345 endpoint_guid: guid_to_16(s.reader.guid()),
6346 // Reader requested-reliability is not retained in the
6347 // slot; RELIABLE is the rmw default (best-effort field).
6348 reliable: true,
6349 transient_local: !matches!(
6350 s.durability,
6351 zerodds_qos::DurabilityKind::Volatile
6352 ),
6353 deadline_seconds: secs(s.deadline_nanos),
6354 lifespan_seconds: 0,
6355 liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
6356 });
6357 }
6358 }
6359 }
6360 if let Ok(s) = self.sedp.lock() {
6361 for sub in s.cache().subscriptions() {
6362 out.push(DiscoveredEndpointInfo {
6363 topic_name: sub.data.topic_name.clone(),
6364 type_name: sub.data.type_name.clone(),
6365 endpoint_guid: guid_to_16(sub.data.key),
6366 reliable: matches!(
6367 sub.data.reliability.kind,
6368 zerodds_qos::ReliabilityKind::Reliable
6369 ),
6370 transient_local: !matches!(
6371 sub.data.durability,
6372 zerodds_qos::DurabilityKind::Volatile
6373 ),
6374 deadline_seconds: sub.data.deadline.period.seconds,
6375 lifespan_seconds: 0,
6376 liveliness_lease_seconds: sub.data.liveliness.lease_duration.seconds,
6377 });
6378 }
6379 }
6380 out
6381 }
6382
6383 /// Number of matched remote readers for a local user writer.
6384 /// Polled by `DataWriter::wait_for_matched_subscription`.
6385 #[must_use]
6386 pub fn user_writer_matched_count(&self, eid: EntityId) -> usize {
6387 // Distinct matched subscriptions = remote/cross-participant reader
6388 // proxies UNION same-participant (intra-runtime) local readers. The
6389 // intra-runtime self-match path delivers samples without adding a wire
6390 // reader-proxy (avoids UDP-to-self double-delivery), so its matches
6391 // would otherwise be invisible to `wait_for_matched_subscription`.
6392 self.user_writer_matched_subscription_handles(eid).len()
6393 }
6394
6395 /// List of `InstanceHandle`s of all matched readers for a local
6396 /// user writer (Spec §2.2.2.4.2.x `get_matched_subscriptions`): remote/
6397 /// cross-participant readers (reader proxies) plus the same-participant
6398 /// readers from the intra-runtime routes, deduplicated by GUID.
6399 #[must_use]
6400 pub fn user_writer_matched_subscription_handles(
6401 &self,
6402 eid: EntityId,
6403 ) -> Vec<crate::instance_handle::InstanceHandle> {
6404 let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
6405 .writer_slot(eid)
6406 .and_then(|arc| {
6407 arc.lock().ok().map(|s| {
6408 s.writer
6409 .reader_proxies()
6410 .iter()
6411 .map(|p| {
6412 crate::instance_handle::InstanceHandle::from_guid(p.remote_reader_guid)
6413 })
6414 .collect::<Vec<_>>()
6415 })
6416 })
6417 .unwrap_or_default();
6418 for h in self.intra_runtime_writer_matched_readers(eid) {
6419 if !handles.contains(&h) {
6420 handles.push(h);
6421 }
6422 }
6423 handles
6424 }
6425
6426 /// Same-participant readers that the local writer `eid` delivers to via
6427 /// an intra-runtime route (as matched-subscription handles).
6428 fn intra_runtime_writer_matched_readers(
6429 &self,
6430 writer_eid: EntityId,
6431 ) -> Vec<crate::instance_handle::InstanceHandle> {
6432 match self.intra_runtime_routes.read() {
6433 Ok(g) => g
6434 .get(&writer_eid)
6435 .map(|readers| {
6436 readers
6437 .iter()
6438 .map(|reid| {
6439 crate::instance_handle::InstanceHandle::from_guid(Guid::new(
6440 self.guid_prefix,
6441 *reid,
6442 ))
6443 })
6444 .collect()
6445 })
6446 .unwrap_or_default(),
6447 Err(_) => Vec::new(),
6448 }
6449 }
6450
6451 /// Same-participant writers that deliver to the local
6452 /// reader `reader_eid` via an intra-runtime route (as matched-publication handles).
6453 fn intra_runtime_reader_matched_writers(
6454 &self,
6455 reader_eid: EntityId,
6456 ) -> Vec<crate::instance_handle::InstanceHandle> {
6457 match self.intra_runtime_routes.read() {
6458 Ok(g) => g
6459 .iter()
6460 .filter(|(_, readers)| readers.contains(&reader_eid))
6461 .map(|(weid, _)| {
6462 crate::instance_handle::InstanceHandle::from_guid(Guid::new(
6463 self.guid_prefix,
6464 *weid,
6465 ))
6466 })
6467 .collect(),
6468 Err(_) => Vec::new(),
6469 }
6470 }
6471
6472 /// List of `InstanceHandle`s of all matched remote writers for a
6473 /// local user reader (Spec §2.2.2.5.x `get_matched_publications`).
6474 #[must_use]
6475 pub fn user_reader_matched_publication_handles(
6476 &self,
6477 eid: EntityId,
6478 ) -> Vec<crate::instance_handle::InstanceHandle> {
6479 let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
6480 .reader_slot(eid)
6481 .and_then(|arc| {
6482 arc.lock().ok().map(|s| {
6483 s.reader
6484 .writer_proxies()
6485 .iter()
6486 .map(|p| {
6487 crate::instance_handle::InstanceHandle::from_guid(
6488 p.proxy.remote_writer_guid,
6489 )
6490 })
6491 .collect::<Vec<_>>()
6492 })
6493 })
6494 .unwrap_or_default();
6495 for h in self.intra_runtime_reader_matched_writers(eid) {
6496 if !handles.contains(&h) {
6497 handles.push(h);
6498 }
6499 }
6500 handles
6501 }
6502
6503 /// Counter for missed offered deadlines on the user writer.
6504 /// Spec OMG DDS 1.4 §2.2.4.2.9 `OFFERED_DEADLINE_MISSED_STATUS`.
6505 #[must_use]
6506 pub fn user_writer_offered_deadline_missed(&self, eid: EntityId) -> u64 {
6507 self.writer_slot(eid)
6508 .and_then(|arc| arc.lock().ok().map(|s| s.offered_deadline_missed_count))
6509 .unwrap_or(0)
6510 }
6511
6512 /// Counter for missed requested deadlines on the user reader.
6513 /// Spec §2.2.4.2.11 `REQUESTED_DEADLINE_MISSED_STATUS`.
6514 #[must_use]
6515 pub fn user_reader_requested_deadline_missed(&self, eid: EntityId) -> u64 {
6516 self.reader_slot(eid)
6517 .and_then(|arc| arc.lock().ok().map(|s| s.requested_deadline_missed_count))
6518 .unwrap_or(0)
6519 }
6520
6521 /// Current liveliness status of a local user reader.
6522 /// Spec §2.2.4.2.14 `LIVELINESS_CHANGED_STATUS`:
6523 /// `(alive, alive_count, not_alive_count)`.
6524 #[must_use]
6525 pub fn user_reader_liveliness_status(&self, eid: EntityId) -> (bool, u64, u64) {
6526 self.reader_slot(eid)
6527 .and_then(|arc| {
6528 arc.lock().ok().map(|s| {
6529 (
6530 s.liveliness_alive,
6531 s.liveliness_alive_count,
6532 s.liveliness_not_alive_count,
6533 )
6534 })
6535 })
6536 .unwrap_or((false, 0, 0))
6537 }
6538
6539 /// LivelinessLost counter on the user writer (Spec §2.2.4.2.10).
6540 /// Incremented by `check_writer_liveliness`.
6541 #[must_use]
6542 pub fn user_writer_liveliness_lost(&self, eid: EntityId) -> u64 {
6543 self.writer_slot(eid)
6544 .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_lost_count))
6545 .unwrap_or(0)
6546 }
6547
6548 /// Snapshot of OfferedIncompatibleQosStatus on the writer.
6549 #[must_use]
6550 pub fn user_writer_offered_incompatible_qos(
6551 &self,
6552 eid: EntityId,
6553 ) -> crate::status::OfferedIncompatibleQosStatus {
6554 self.writer_slot(eid)
6555 .and_then(|arc| arc.lock().ok().map(|s| s.offered_incompatible_qos.clone()))
6556 .unwrap_or_default()
6557 }
6558
6559 /// Snapshot of RequestedIncompatibleQosStatus on the reader.
6560 #[must_use]
6561 pub fn user_reader_requested_incompatible_qos(
6562 &self,
6563 eid: EntityId,
6564 ) -> crate::status::RequestedIncompatibleQosStatus {
6565 self.reader_slot(eid)
6566 .and_then(|arc| {
6567 arc.lock()
6568 .ok()
6569 .map(|s| s.requested_incompatible_qos.clone())
6570 })
6571 .unwrap_or_default()
6572 }
6573
6574 /// Sample-lost counter (reader side). Spec §2.2.4.2.6.2.
6575 #[must_use]
6576 pub fn user_reader_sample_lost(&self, eid: EntityId) -> u64 {
6577 self.reader_slot(eid)
6578 .and_then(|arc| arc.lock().ok().map(|s| s.sample_lost_count))
6579 .unwrap_or(0)
6580 }
6581
6582 /// Monotonically increasing count of alive samples delivered to the
6583 /// user (Spec §2.2.4.2.6.1 `on_data_available` detector). A delta
6584 /// against the last poll snapshot means "new data available".
6585 #[must_use]
6586 pub fn user_reader_samples_delivered(&self, eid: EntityId) -> u64 {
6587 self.reader_slot(eid)
6588 .and_then(|arc| arc.lock().ok().map(|s| s.samples_delivered_count))
6589 .unwrap_or(0)
6590 }
6591
6592 /// A2 — arm TIME_BASED_FILTER (DDS 1.4 §2.2.3.12) on a runtime/C-FFI user
6593 /// reader: it then receives at most one sample per instance per
6594 /// `min_separation_nanos`; closer-spaced samples are dropped before they
6595 /// reach the reader's channel. `0` disables the filter. Returns `true` if
6596 /// the reader exists. This is the seam `rmw_zerodds` uses to rate-limit ROS-2
6597 /// subscriptions (`rmw_qos_profile_t` carries no TIME_BASED_FILTER field).
6598 pub fn set_user_reader_time_based_filter(
6599 &self,
6600 eid: EntityId,
6601 min_separation_nanos: u128,
6602 ) -> bool {
6603 let Some(arc) = self.reader_slot(eid) else {
6604 return false;
6605 };
6606 let Ok(mut slot) = arc.lock() else {
6607 return false;
6608 };
6609 slot.tbf_min_separation_nanos = min_separation_nanos;
6610 if min_separation_nanos == 0 {
6611 slot.tbf_last_delivered.clear();
6612 }
6613 true
6614 }
6615
6616 /// Bug-2 diagnosis (2026-05-19): number of submessages dropped
6617 /// because of an unknown writer_id. If this value is incremented
6618 /// after a write, it indicates an SEDP match
6619 /// race (writer_proxy not yet added when DATA is received).
6620 #[must_use]
6621 pub fn user_reader_unknown_src_count(&self, eid: EntityId) -> u64 {
6622 self.reader_slot(eid)
6623 .and_then(|arc| arc.lock().ok().map(|s| s.reader.unknown_src_count()))
6624 .unwrap_or(0)
6625 }
6626
6627 /// Sample-rejected status (reader side). Spec §2.2.4.2.6.3.
6628 #[must_use]
6629 pub fn user_reader_sample_rejected(
6630 &self,
6631 eid: EntityId,
6632 ) -> crate::status::SampleRejectedStatus {
6633 self.reader_slot(eid)
6634 .and_then(|arc| arc.lock().ok().map(|s| s.sample_rejected))
6635 .unwrap_or_default()
6636 }
6637
6638 /// Records a lost sample on the user reader. Called
6639 /// by resource-limit or decode-failure paths — the
6640 /// detector is application-external, because sample-lost depending on the
6641 /// implementation comes from several sources (cache drop, decode
6642 /// fail, sequence-number gap drop).
6643 pub fn record_sample_lost(&self, eid: EntityId, count: u32) {
6644 if count == 0 {
6645 return;
6646 }
6647 if let Some(arc) = self.reader_slot(eid) {
6648 if let Ok(mut slot) = arc.lock() {
6649 slot.sample_lost_count = slot.sample_lost_count.saturating_add(u64::from(count));
6650 }
6651 }
6652 }
6653
6654 /// Records a rejected sample on the user reader.
6655 pub fn record_sample_rejected(
6656 &self,
6657 eid: EntityId,
6658 kind: crate::status::SampleRejectedStatusKind,
6659 instance: crate::instance_handle::InstanceHandle,
6660 ) {
6661 if let Some(arc) = self.reader_slot(eid) {
6662 if let Ok(mut slot) = arc.lock() {
6663 slot.sample_rejected.total_count =
6664 slot.sample_rejected.total_count.saturating_add(1);
6665 slot.sample_rejected.last_reason = kind;
6666 slot.sample_rejected.last_instance_handle = instance;
6667 }
6668 }
6669 }
6670
6671 /// Manual liveliness assert on the user writer. Sets the
6672 /// `last_liveliness_assert` timestamp. For `LivelinessKind::Automatic`
6673 /// `last_write` is also set — the liveliness path
6674 /// otherwise never falls through the `assert` trigger, because every successful
6675 /// `write` already takes over the liveliness tick.
6676 pub fn assert_writer_liveliness_eid(&self, eid: EntityId) {
6677 let now = self.start_instant.elapsed();
6678 if let Some(arc) = self.writer_slot(eid) {
6679 if let Ok(mut slot) = arc.lock() {
6680 slot.last_liveliness_assert = Some(now);
6681 if slot.liveliness_kind == zerodds_qos::LivelinessKind::Automatic {
6682 slot.last_write = Some(now);
6683 }
6684 }
6685 }
6686 }
6687
6688 /// True if all matched readers have acknowledged all samples written
6689 /// so far. Empty cache or no proxies → true.
6690 #[must_use]
6691 pub fn user_writer_all_acknowledged(&self, eid: EntityId) -> bool {
6692 self.writer_slot(eid)
6693 .and_then(|arc| arc.lock().ok().map(|s| s.writer.all_samples_acknowledged()))
6694 .unwrap_or(true)
6695 }
6696
6697 /// Test helper — pushes a synthetic `UserSample::Alive`
6698 /// directly into the `mpsc::Sender` of the given reader, without
6699 /// going through the wire/discovery path. Enables end-to-end tests of
6700 /// downstream consumers (e.g. bridge-daemon pumps) that otherwise
6701 /// become flaky in CI containers due to multicast-loopback limits.
6702 /// **Not** for production code.
6703 ///
6704 /// `writer_guid` and `writer_strength` are set to default values
6705 /// (shared-ownership assumption).
6706 ///
6707 /// Returns `true` if the reader slot exists and the push
6708 /// succeeded, `false` if the EID is unknown or the channel is
6709 /// closed.
6710 #[doc(hidden)]
6711 pub fn test_inject_user_alive(&self, eid: EntityId, payload: Vec<u8>) -> bool {
6712 let Some(arc) = self.reader_slot(eid) else {
6713 return false;
6714 };
6715 let Ok(mut slot) = arc.lock() else {
6716 return false;
6717 };
6718 let sent = slot
6719 .sample_tx
6720 .send(UserSample::Alive {
6721 payload: crate::sample_bytes::SampleBytes::from_vec(payload),
6722 writer_guid: [0u8; 16],
6723 writer_strength: 0,
6724 representation: 0,
6725 big_endian: false,
6726 source_timestamp: None,
6727 })
6728 .is_ok();
6729 if sent {
6730 slot.samples_delivered_count = slot.samples_delivered_count.saturating_add(1);
6731 }
6732 sent
6733 }
6734
6735 /// Test helper — bumps the inconsistent-topic counter as if matching had
6736 /// discovered a remote endpoint with the same `topic_name` but a
6737 /// different `type_name`. Lets listener-FFI tests exercise the
6738 /// `on_inconsistent_topic` poll path without standing up two
6739 /// participants with a real SEDP type mismatch. **Not** for production.
6740 #[doc(hidden)]
6741 pub fn test_bump_inconsistent_topic(&self) {
6742 self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
6743 }
6744
6745 /// Spec §3.1 zerodds-async-1.0: registers the waker of an
6746 /// async reader in the UserReaderSlot. On `sample_tx.send`
6747 /// the waker is woken. `None` as the argument clears the waker
6748 /// (e.g. after the async reader is dropped).
6749 pub fn register_user_reader_waker(&self, eid: EntityId, waker: Option<core::task::Waker>) {
6750 if let Some(arc) = self.reader_slot(eid) {
6751 if let Ok(slot) = arc.lock() {
6752 if let Ok(mut g) = slot.async_waker.lock() {
6753 *g = waker;
6754 }
6755 }
6756 }
6757 }
6758
6759 /// Register a listener callback for alive-sample
6760 /// arrival on the user reader. `None` clears an
6761 /// existing listener.
6762 ///
6763 /// The listener fires synchronously on the recv thread of
6764 /// `recv_user_data_loop` — see the contract doc on the
6765 /// [`UserReaderListener`] type. Eliminates the user-polling
6766 /// latency (~50-100 µs) compared to `sample_tx.recv()`.
6767 ///
6768 /// Returns `true` if the reader slot exists and the listener
6769 /// was set, `false` if the EID is not a known user reader.
6770 pub fn set_user_reader_listener(
6771 &self,
6772 eid: EntityId,
6773 listener: Option<UserReaderListener>,
6774 ) -> bool {
6775 let Some(arc) = self.reader_slot(eid) else {
6776 return false;
6777 };
6778 let Ok(mut slot) = arc.lock() else {
6779 return false;
6780 };
6781 slot.listener = listener.map(alloc::sync::Arc::new);
6782 true
6783 }
6784
6785 /// Number of matched writers for a local user reader: remote/cross-
6786 /// participant writers (writer proxies) plus same-participant writers from the
6787 /// intra-runtime routes, deduplicated by GUID (symmetric to the writer).
6788 #[must_use]
6789 pub fn user_reader_matched_count(&self, eid: EntityId) -> usize {
6790 self.user_reader_matched_publication_handles(eid).len()
6791 }
6792
6793 /// D.5e Phase-1 — waits until a match event occurs or the timeout
6794 /// is reached. Replaces 20-ms polling in `DataReader::wait_for_matched_*`
6795 /// and `DataWriter::wait_for_matched_*`.
6796 ///
6797 /// The caller checks the match count itself (via `user_*_matched_count`)
6798 /// before and after the wait — this function is only the block mechanics.
6799 /// Returns `false` if the timeout is reached, `true` if a notify came.
6800 #[cfg(feature = "std")]
6801 pub fn wait_match_event(&self, timeout: core::time::Duration) -> bool {
6802 let (lock, cvar) = &*self.match_event;
6803 let Ok(guard) = lock.lock() else { return false };
6804 match cvar.wait_timeout(guard, timeout) {
6805 Ok((_, t)) => !t.timed_out(),
6806 Err(_) => false,
6807 }
6808 }
6809
6810 /// D.5e Phase-1 — waits until an ACK event occurs or a timeout.
6811 /// Replaces 50-ms polling in `DataWriter::wait_for_acknowledgments`.
6812 #[cfg(feature = "std")]
6813 pub fn wait_ack_event(&self, timeout: core::time::Duration) -> bool {
6814 let (lock, cvar) = &*self.ack_event;
6815 let Ok(guard) = lock.lock() else { return false };
6816 match cvar.wait_timeout(guard, timeout) {
6817 Ok((_, t)) => !t.timed_out(),
6818 Err(_) => false,
6819 }
6820 }
6821
6822 /// D.5e Phase-1 — notify helper for the ACK event. Called by the reliable
6823 /// writer path when an ACKNACK advances the acked-base.
6824 #[cfg(feature = "std")]
6825 pub(crate) fn notify_ack_event(&self) {
6826 self.ack_event.1.notify_all();
6827 }
6828
6829 /// ADR-0006 — sets the PID_SHM_LOCATOR bytes for a local
6830 /// user writer in the side map. Called by the DataWriter
6831 /// once `set_flat_backend` has attached a same-host backend (POSIX shm /
6832 /// Iceoryx2). On the next SEDP push the wire encoder
6833 /// injects PID 0x8001 into the `PublicationData`.
6834 pub fn set_shm_locator(&self, eid: EntityId, bytes: Vec<u8>) {
6835 if let Ok(mut g) = self.shm_locators.write() {
6836 g.insert(eid, bytes);
6837 }
6838 }
6839
6840 /// ADR-0006 — reads the PID_SHM_LOCATOR bytes for a local
6841 /// user writer from the side map. Returns `None` if no
6842 /// same-host backend is set.
6843 #[must_use]
6844 pub fn shm_locator(&self, eid: EntityId) -> Option<Vec<u8>> {
6845 self.shm_locators.read().ok()?.get(&eid).cloned()
6846 }
6847
6848 /// ADR-0006 — removes the PID_SHM_LOCATOR entry (e.g. when the
6849 /// user writer is reconfigured without a backend).
6850 pub fn clear_shm_locator(&self, eid: EntityId) {
6851 if let Ok(mut g) = self.shm_locators.write() {
6852 g.remove(&eid);
6853 }
6854 }
6855
6856 /// Stops all worker threads (recv loops + tick loop) and joins
6857 /// them. Idempotent — repeated calls are no-ops.
6858 ///
6859 /// Shutdown delay: up to ~1 s, because the recv threads sit in
6860 /// `recv()` with a 1 s read timeout. After the
6861 /// current recv() call finishes they check the stop flag and
6862 /// terminate.
6863 pub fn shutdown(&self) {
6864 self.stop.store(true, Ordering::Relaxed);
6865 // D.5e Phase 3 — wake the scheduler tick worker so it observes `stop`
6866 // immediately instead of parking up to the idle floor.
6867 if let Ok(guard) = self.tick_wake.lock() {
6868 if let Some(h) = guard.as_ref() {
6869 h.stop();
6870 }
6871 }
6872 if let Ok(mut guard) = self.handles.lock() {
6873 for h in guard.drain(..) {
6874 let _ = h.join();
6875 }
6876 }
6877 }
6878}
6879
6880impl Drop for DcpsRuntime {
6881 // ZERODDS_PHASE_DUMP=1 is on-demand debug telemetry for
6882 // phase-latency profiling. eprintln is semantically correct here
6883 // (stderr diagnostics), no log-crate dependency wanted.
6884 #[allow(clippy::print_stderr)]
6885 fn drop(&mut self) {
6886 if std::env::var("ZERODDS_PHASE_DUMP")
6887 .map(|s| s == "1")
6888 .unwrap_or(false)
6889 {
6890 let hu_ns = PHASE_HANDLE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
6891 let hu_n = PHASE_HANDLE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
6892 let wu_ns = PHASE_WRITE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
6893 let wu_n = PHASE_WRITE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
6894 let hu_us = if hu_n > 0 {
6895 hu_ns as f64 / hu_n as f64 / 1000.0
6896 } else {
6897 0.0
6898 };
6899 let wu_us = if wu_n > 0 {
6900 wu_ns as f64 / wu_n as f64 / 1000.0
6901 } else {
6902 0.0
6903 };
6904 eprintln!(
6905 "[ZERODDS_PHASE] handle_user_datagram: N={} avg={:.3}us total={:.1}ms",
6906 hu_n,
6907 hu_us,
6908 hu_ns as f64 / 1_000_000.0
6909 );
6910 eprintln!(
6911 "[ZERODDS_PHASE] write_user_sample: N={} avg={:.3}us total={:.1}ms",
6912 wu_n,
6913 wu_us,
6914 wu_ns as f64 / 1_000_000.0
6915 );
6916 // Sub-phases of write_user_sample_borrowed.
6917 // [0] slot_lookup, [1] slot_lock_acquire,
6918 // [2] writer.write + framing, [3] dispatch (UDP + inproc).
6919 const SUB_LABELS: [&str; 4] = [
6920 " ├─ slot_lookup ",
6921 " ├─ slot_lock_acquire ",
6922 " ├─ writer.write+frame",
6923 " └─ dispatch (UDP+...)",
6924 ];
6925 for (i, label) in SUB_LABELS.iter().enumerate() {
6926 let s_ns = PHASE_WRITE_SUB_NS[i].load(core::sync::atomic::Ordering::Relaxed);
6927 if s_ns > 0 && wu_n > 0 {
6928 let s_us = s_ns as f64 / wu_n as f64 / 1000.0;
6929 eprintln!(
6930 "[ZERODDS_PHASE] {} avg={:.3}us total={:.1}ms",
6931 label,
6932 s_us,
6933 s_ns as f64 / 1_000_000.0
6934 );
6935 }
6936 }
6937 }
6938 self.shutdown();
6939 }
6940}
6941
6942// ---------------------------------------------------------------------
6943// Worker threads (Sprint D.5b — per-socket recv + central tick).
6944//
6945// Before: a single `event_loop` that went through three sequential
6946// blocking `recv()`s with a `tick_period` timeout (50 ms) per iteration.
6947// Roundtrip latency: 5-14 ms p50 (CFS drift + sequential wait stages).
6948//
6949// Now: four dedicated threads.
6950// * recv_spdp_multicast_loop — blocks on the SPDP multicast socket
6951// * recv_metatraffic_loop — blocks on SPDP unicast (= metatraffic)
6952// * recv_user_data_loop — blocks on user-data unicast
6953// * tick_loop — periodic outbound tasks +
6954// per-interface inbound (non-blocking) +
6955// deadline/lifespan/liveliness
6956//
6957// Lock discipline: the recv threads and the tick thread contend for
6958// `rt.sedp.lock()` / `rt.wlp.lock()` / per-slot `slot.lock()`.
6959// Convention: keep lock-hold times short (handle_datagram + tick each
6960// have only single-pass logic), no sub-lock under sedp/wlp.
6961// ---------------------------------------------------------------------
6962
6963/// Sprint D.5d lever C — applies SCHED_FIFO + CPU affinity to the
6964/// calling thread. Linux-only; no-op on macOS/Windows.
6965///
6966/// Called by every worker loop right at the start, so
6967/// the syscalls run on the actual worker thread
6968/// (`pthread_self()` must come from the thread itself).
6969///
6970/// Failures are logged to stderr but are not fatal — if
6971/// the process has no `CAP_SYS_NICE`, the runtime continues with
6972/// the CFS default scheduler.
6973#[allow(unused_variables)]
6974fn apply_thread_tuning(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
6975 #[cfg(target_os = "linux")]
6976 rt_pinning::apply(label, priority, cpus);
6977}
6978
6979/// Linux-only `pthread_setschedparam` + `sched_setaffinity` wrapper.
6980/// A dedicated module encapsulates the `unsafe` locally with safety notes; the
6981/// crate-level `#![deny(unsafe_code)]` stays active for the rest of the dcps
6982/// codebase.
6983#[cfg(target_os = "linux")]
6984#[allow(unsafe_code, clippy::print_stderr)]
6985mod rt_pinning {
6986 pub(super) fn apply(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
6987 if let Some(prio) = priority {
6988 // SAFETY: libc FFI with an owned `param` struct. The self-thread via
6989 // `pthread_self()` is always valid.
6990 // musl libc has additional `sched_ss_*` fields (POSIX
6991 // sporadic-server) that we do not set — `mem::zeroed`
6992 // initializes them cleanly to 0.
6993 unsafe {
6994 let mut param: libc::sched_param = core::mem::zeroed();
6995 param.sched_priority = prio;
6996 let rc = libc::pthread_setschedparam(
6997 libc::pthread_self(),
6998 libc::SCHED_FIFO,
6999 &raw const param,
7000 );
7001 if rc != 0 {
7002 eprintln!(
7003 "zdds[{label}]: pthread_setschedparam SCHED_FIFO {prio} \
7004 failed (rc={rc}). Need CAP_SYS_NICE or RLIMIT_RTPRIO."
7005 );
7006 }
7007 }
7008 }
7009 if let Some(cpu_list) = cpus {
7010 // SAFETY: cpu_set_t is POD; CPU_ZERO/SET are libc inline
7011 // functions without lifetime requirements.
7012 unsafe {
7013 let mut set: libc::cpu_set_t = core::mem::zeroed();
7014 libc::CPU_ZERO(&mut set);
7015 for &cpu in cpu_list {
7016 if cpu < libc::CPU_SETSIZE as usize {
7017 libc::CPU_SET(cpu, &mut set);
7018 }
7019 }
7020 let rc = libc::sched_setaffinity(
7021 0,
7022 core::mem::size_of::<libc::cpu_set_t>(),
7023 &raw const set,
7024 );
7025 if rc != 0 {
7026 eprintln!("zdds[{label}]: sched_setaffinity({cpu_list:?}) failed.");
7027 }
7028 }
7029 }
7030 }
7031}
7032
7033/// FastDDS interop (phase 2): acknowledges FastDDS' reliable secure SPDP writer
7034/// (0xff0101c2). FastDDS heartbeats its secure SPDP reliably and sends the
7035/// `participant_crypto_tokens` only once our 0xff0101c7 reader has acked its writer
7036/// (fast<->fast reference pcap: ACKNACK on 0xff0101c7). We respond to
7037/// every incoming secure-SPDP HEARTBEAT with an ACKNACK (base = last+1,
7038/// final), addressed via INFO_DST to the sender prefix. Gated on
7039/// `enable_secure_spdp`.
7040#[cfg(feature = "security")]
7041fn secure_spdp_reader_acks(rt: &DcpsRuntime, clear: &[u8]) -> Vec<Vec<u8>> {
7042 use zerodds_rtps::header::RtpsHeader;
7043 use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
7044 use zerodds_rtps::submessages::{AckNackSubmessage, HeartbeatSubmessage, SequenceNumberSet};
7045 use zerodds_rtps::wire_types::SequenceNumber;
7046 if !rt.config.enable_secure_spdp {
7047 return Vec::new();
7048 }
7049 let Ok(parsed) = decode_datagram(clear) else {
7050 return Vec::new();
7051 };
7052 let peer_prefix = parsed.header.guid_prefix;
7053 let mut out = Vec::new();
7054 let mut count = 0i32;
7055 let secure_writer = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER;
7056 let secure_reader = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER;
7057 // Header + INFO_DST(peer) + submessage. INFO_DST is mandatory, otherwise the
7058 // dest prefix is UNKNOWN -> FastDDS discards it as "not a connection".
7059 let wrap = |id: SubmessageId, body: &[u8], flags: u8| -> Option<Vec<u8>> {
7060 let blen = u16::try_from(body.len()).ok()?;
7061 let header = RtpsHeader::new(VendorId::ZERODDS, rt.guid_prefix);
7062 let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
7063 dg.extend_from_slice(&header.to_bytes());
7064 let info = SubmessageHeader {
7065 submessage_id: SubmessageId::InfoDst,
7066 flags: FLAG_E_LITTLE_ENDIAN,
7067 octets_to_next_header: 12,
7068 };
7069 dg.extend_from_slice(&info.to_bytes());
7070 dg.extend_from_slice(&peer_prefix.to_bytes());
7071 let sh = SubmessageHeader {
7072 submessage_id: id,
7073 flags: flags | FLAG_E_LITTLE_ENDIAN,
7074 octets_to_next_header: blen,
7075 };
7076 dg.extend_from_slice(&sh.to_bytes());
7077 dg.extend_from_slice(body);
7078 Some(dg)
7079 };
7080 for sub in &parsed.submessages {
7081 match sub {
7082 // FastDDS' secure-SPDP writer HEARTBEAT -> we ack (reader 0xff0101c7).
7083 ParsedSubmessage::Heartbeat(hb) if hb.writer_id == secure_writer => {
7084 count = count.wrapping_add(1);
7085 let ack = AckNackSubmessage {
7086 reader_id: secure_reader,
7087 writer_id: secure_writer,
7088 reader_sn_state: SequenceNumberSet {
7089 bitmap_base: SequenceNumber(hb.last_sn.0 + 1),
7090 num_bits: 0,
7091 bitmap: Vec::new(),
7092 },
7093 count,
7094 final_flag: true,
7095 };
7096 let (body, flags) = ack.write_body(true);
7097 if let Some(dg) = wrap(SubmessageId::AckNack, &body, flags) {
7098 out.push(dg);
7099 }
7100 }
7101 // FastDDS' reader requests (preemptive ACKNACK to our 0xff0101c2
7102 // writer) our secure-SPDP data reliably -> deliver DATA(SN=1) +
7103 // HEARTBEAT(1,1), otherwise FastDDS' reader never matches and
7104 // sends no crypto_tokens.
7105 ParsedSubmessage::AckNack(a) if a.writer_id == secure_writer => {
7106 if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7107 if let Ok(data_dg) = beacon.serialize_secure() {
7108 out.push(protect_secure_spdp(rt, &data_dg).unwrap_or(data_dg));
7109 }
7110 }
7111 count = count.wrapping_add(1);
7112 let hbsm = HeartbeatSubmessage {
7113 reader_id: secure_reader,
7114 writer_id: secure_writer,
7115 first_sn: SequenceNumber(1),
7116 last_sn: SequenceNumber(1),
7117 count,
7118 final_flag: false,
7119 liveliness_flag: false,
7120 group_info: None,
7121 };
7122 let (body, flags) = hbsm.write_body(true);
7123 if let Some(dg) = wrap(SubmessageId::Heartbeat, &body, flags) {
7124 out.push(dg);
7125 }
7126 }
7127 _ => {}
7128 }
7129 }
7130 out
7131}
7132
7133/// FastDDS interop (phase 2b): builds a secure-SPDP HEARTBEAT (writer
7134/// 0xff0101c2, first=1/last=1) with INFO_DST to `peer_prefix`. Sent periodically per
7135/// discovered peer, so FastDDS' reliable secure-SPDP reader is solicited to a
7136/// (preemptive) ACKNACK and matches our writer.
7137#[cfg(feature = "security")]
7138fn build_secure_spdp_heartbeat(
7139 local_prefix: GuidPrefix,
7140 peer_prefix: GuidPrefix,
7141 count: i32,
7142) -> Option<Vec<u8>> {
7143 use zerodds_rtps::header::RtpsHeader;
7144 use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
7145 use zerodds_rtps::submessages::HeartbeatSubmessage;
7146 use zerodds_rtps::wire_types::SequenceNumber;
7147 let hb = HeartbeatSubmessage {
7148 reader_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
7149 writer_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
7150 first_sn: SequenceNumber(1),
7151 last_sn: SequenceNumber(1),
7152 count,
7153 final_flag: false,
7154 liveliness_flag: false,
7155 group_info: None,
7156 };
7157 let (body, flags) = hb.write_body(true);
7158 let blen = u16::try_from(body.len()).ok()?;
7159 let header = RtpsHeader::new(VendorId::ZERODDS, local_prefix);
7160 let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
7161 dg.extend_from_slice(&header.to_bytes());
7162 let info = SubmessageHeader {
7163 submessage_id: SubmessageId::InfoDst,
7164 flags: FLAG_E_LITTLE_ENDIAN,
7165 octets_to_next_header: 12,
7166 };
7167 dg.extend_from_slice(&info.to_bytes());
7168 dg.extend_from_slice(&peer_prefix.to_bytes());
7169 let sh = SubmessageHeader {
7170 submessage_id: SubmessageId::Heartbeat,
7171 flags: flags | FLAG_E_LITTLE_ENDIAN,
7172 octets_to_next_header: blen,
7173 };
7174 dg.extend_from_slice(&sh.to_bytes());
7175 dg.extend_from_slice(&body);
7176 Some(dg)
7177}
7178
7179/// FastDDS interop: SEC-protects the secure-SPDP DATA (0xff0101c2) under
7180/// `discovery_protection != NONE` — FastDDS then encrypts the secure-SPDP DATA
7181/// (like the secure SEDP), and a PLAIN secure SPDP is discarded. Wraps
7182/// the DATA submessage with the per-endpoint writer key (0xff0101c2) as
7183/// SEC_PREFIX/BODY/POSTFIX; framing submessages (INFO_*) stay. Without
7184/// discovery_protection (common subset) passthrough. `None` on a crypto error.
7185#[cfg(feature = "security")]
7186fn protect_secure_spdp(rt: &DcpsRuntime, datagram: &[u8]) -> Option<Vec<u8>> {
7187 let gate = rt.config.security.as_ref()?;
7188 if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None
7189 || datagram.len() < 20
7190 {
7191 return Some(datagram.to_vec());
7192 }
7193 let h = local_endpoint_crypto_handle(
7194 rt,
7195 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
7196 true,
7197 )?;
7198 let mut out = datagram[..20].to_vec();
7199 for (id, start, total) in walk_submessages(datagram) {
7200 let submsg = &datagram[start..start + total];
7201 if id == SMID_DATA {
7202 match gate.encode_data_datawriter_by_handle(h, submsg) {
7203 Ok(s) => out.extend_from_slice(&s),
7204 Err(_) => return None,
7205 }
7206 } else {
7207 out.extend_from_slice(submsg);
7208 }
7209 }
7210 Some(out)
7211}
7212
7213/// Worker: blocks on the SPDP multicast socket, dispatches SPDP beacons +
7214/// WLP heartbeats that come in over multicast.
7215fn recv_spdp_multicast_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7216 apply_thread_tuning(
7217 "recv-spdp-mc",
7218 rt.config.recv_thread_priority,
7219 rt.config.recv_thread_cpus.as_deref(),
7220 );
7221 while !stop.load(Ordering::Relaxed) {
7222 let elapsed = rt.start_instant.elapsed();
7223 let sedp_now = Duration::from_secs(elapsed.as_secs())
7224 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7225 let Ok(dg) = rt.spdp_multicast_rx.recv() else {
7226 continue;
7227 };
7228 #[cfg(feature = "security")]
7229 let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7230 #[cfg(not(feature = "security"))]
7231 let clear = secure_inbound_bytes(&rt, &dg.data);
7232 if let Some(clear) = clear {
7233 handle_spdp_datagram(&rt, &clear);
7234 // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
7235 // reliably, otherwise FastDDS sends no crypto_tokens.
7236 #[cfg(feature = "security")]
7237 for ack in secure_spdp_reader_acks(&rt, &clear) {
7238 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7239 let _ = rt.spdp_unicast.send(&loc, &ack);
7240 }
7241 }
7242 // WLP heartbeats arrive on the SPDP multicast socket
7243 // (the sender sends them to the SPDP multicast group).
7244 // handle_spdp_datagram ignores them, so we also feed
7245 // the same buffer into the WLP endpoint. A
7246 // secure-WLP DATA is participant-key SEC-protected → decode
7247 // it first (like secure SEDP in the metatraffic loop), otherwise
7248 // wlp.handle_datagram would only see the SEC block.
7249 #[cfg(feature = "security")]
7250 let wlp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
7251 let mut pk = [0u8; 12];
7252 pk.copy_from_slice(&clear[8..20]);
7253 unprotect_user_datagram(&rt, &clear, &pk)
7254 } else {
7255 None
7256 };
7257 #[cfg(feature = "security")]
7258 let wlp_input: &[u8] = wlp_decoded.as_deref().unwrap_or(&clear);
7259 #[cfg(not(feature = "security"))]
7260 let wlp_input: &[u8] = &clear;
7261 if let Ok(mut wlp) = rt.wlp.lock() {
7262 let _ = wlp.handle_datagram(wlp_input, sedp_now);
7263 }
7264 }
7265 }
7266}
7267
7268/// Worker: blocks on SPDP unicast (= metatraffic socket), dispatches
7269/// SPDP reverse beacons + SEDP + WLP + security builtin.
7270fn recv_metatraffic_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7271 apply_thread_tuning(
7272 "recv-meta",
7273 rt.config.recv_thread_priority,
7274 rt.config.recv_thread_cpus.as_deref(),
7275 );
7276 while !stop.load(Ordering::Relaxed) {
7277 let elapsed = rt.start_instant.elapsed();
7278 let sedp_now = Duration::from_secs(elapsed.as_secs())
7279 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7280 let Ok(dg) = rt.spdp_unicast.recv() else {
7281 continue;
7282 };
7283 #[cfg(feature = "security")]
7284 let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7285 #[cfg(not(feature = "security"))]
7286 let clear = secure_inbound_bytes(&rt, &dg.data);
7287 if let Some(clear) = clear {
7288 // A single recv call, both handlers on the same
7289 // datagram. SPDP first (Cyclone reverse beacons), then
7290 // SEDP, then WLP, then security builtin.
7291 handle_spdp_datagram(&rt, &clear);
7292 // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
7293 // reliably (they arrive unicast over the metatraffic socket).
7294 #[cfg(feature = "security")]
7295 for ack in secure_spdp_reader_acks(&rt, &clear) {
7296 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7297 let _ = rt.spdp_unicast.send(&loc, &ack);
7298 }
7299 }
7300 // Protected discovery: secure-SEDP DATA is SEC_* submessage-
7301 // protected (the sender's participant data key). Before the SEDP parse
7302 // decode it with the sender prefix (RTPS header bytes[8..20]); for
7303 // plaintext SEDP (no SEC_*) unprotect_user_datagram returns None
7304 // and we use `clear` unchanged.
7305 #[cfg(feature = "security")]
7306 let sedp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
7307 let mut pk = [0u8; 12];
7308 pk.copy_from_slice(&clear[8..20]);
7309 unprotect_user_datagram(&rt, &clear, &pk)
7310 } else {
7311 None
7312 };
7313 // OPEN (phase 3, internal/security/per-endpoint-crypto-followup.md):
7314 // if `unprotect_user_datagram` fails for a secure-SEDP DATA
7315 // (cyclone's per-endpoint token not yet installed — race),
7316 // `sedp_input` falls back to the SEC_* bytes and the DATA is discarded.
7317 // Cross-vendor (discovery=ENCRYPT) must make this deterministic:
7318 // treat the reliable secure-SEDP DATA as not-received (NACK,
7319 // no SN advance), so the re-send after token install decodes.
7320 #[cfg(feature = "security")]
7321 let sedp_input: &[u8] = sedp_decoded.as_deref().unwrap_or(&clear);
7322 #[cfg(not(feature = "security"))]
7323 let sedp_input: &[u8] = &clear;
7324 let events = {
7325 if let Ok(mut sedp) = rt.sedp.lock() {
7326 sedp.handle_datagram(sedp_input, sedp_now).ok()
7327 } else {
7328 None
7329 }
7330 };
7331 if let Some(ev) = events {
7332 if !ev.is_empty() {
7333 run_matching_pass(&rt);
7334 push_sedp_events_to_builtin_readers(&rt, &ev);
7335 }
7336 }
7337
7338 // Secure WLP (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER) is, like
7339 // secure SEDP, participant-key SEC-protected → feed the decoded variant
7340 // (sedp_input), not the still SEC-wrapped `clear`. For
7341 // plaintext WLP, sedp_input == clear.
7342 let wlp_resends = if let Ok(mut wlp) = rt.wlp.lock() {
7343 let _ = wlp.handle_datagram(sedp_input, sedp_now);
7344 // Reliable resend: if the peer NACKs our (secure-)WLP writer,
7345 // we re-emit the missing beats (cyclone treats WLP as
7346 // reliable; without a resend it would never get the liveliness assertion).
7347 wlp.wlp_acknack_resends(sedp_input)
7348 } else {
7349 Vec::new()
7350 };
7351 for beat in wlp_resends {
7352 if let Some(secured) = protect_wlp_outbound(&rt, &beat) {
7353 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7354 let _ = rt.spdp_unicast.send(&loc, &secured);
7355 }
7356 }
7357 }
7358 for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
7359 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7360 }
7361 }
7362 }
7363}
7364
7365/// Supervisor: wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) — same-host SHM
7366/// receive. Spawns **one dedicated receive thread per bound SHM consumer**, so
7367/// each blocks on its own segment futex ([`PosixShmTransport::recv`] →
7368/// `wait_for_frame`) and dispatches a sample the instant it lands.
7369///
7370/// History: the original single loop iterated all consumers round-robin and
7371/// called the blocking `recv()` on each in turn. With N consumers the
7372/// worst-case per-sample latency was `(N-1) × recv_timeout` (1 ms each, see
7373/// [`crate::same_host_shm::shm_config_for_pair`]) — a single thread cannot wait
7374/// on N futexes at once, so the active consumer's sample waited while the loop
7375/// sat in idle consumers' `recv()` timeouts. Fine for 1-2 same-host peers, but
7376/// it dominated at the many-endpoint scale ROS hits (user topics +
7377/// `ros_discovery_info` + parameter services + `rosout` = a dozen same-host SHM
7378/// consumers), turning a ~30 µs delivery into ~570 µs. The documented fix
7379/// ("multiple threads or epoll-style multiplexing") is realized here as one
7380/// thread per consumer. The supervisor only polls *membership* (discovery-rate,
7381/// not the data path) to spawn/reap workers.
7382#[cfg(feature = "same-host-shm")]
7383fn recv_user_shm_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7384 use crate::same_host::{Role, SameHostState};
7385 use zerodds_transport_shm::PosixShmTransport;
7386
7387 apply_thread_tuning(
7388 "recv-shm-sup",
7389 rt.config.recv_thread_priority,
7390 rt.config.recv_thread_cpus.as_deref(),
7391 );
7392 // segment-id → (per-worker stop flag, join handle).
7393 let mut workers: std::collections::HashMap<
7394 [u8; 16],
7395 (Arc<AtomicBool>, thread::JoinHandle<()>),
7396 > = std::collections::HashMap::new();
7397 // Membership poll is discovery-rate, NOT the data path: it only detects
7398 // newly-bound / vanished consumers to spawn / reap their worker thread.
7399 let membership_poll = Duration::from_millis(100);
7400 while !stop.load(Ordering::Relaxed) {
7401 let mut live: std::collections::HashSet<[u8; 16]> = std::collections::HashSet::new();
7402 for (w, r, state) in rt.same_host.snapshot() {
7403 let SameHostState::Bound { transport, role } = state else {
7404 continue;
7405 };
7406 if !matches!(role, Role::Consumer) {
7407 continue;
7408 }
7409 let Ok(consumer) = transport.downcast::<PosixShmTransport>() else {
7410 continue;
7411 };
7412 let key = crate::same_host::shm_segment_id_for_pair(w, r);
7413 live.insert(key);
7414 if workers.contains_key(&key) {
7415 continue;
7416 }
7417 let wstop = Arc::new(AtomicBool::new(false));
7418 let (rt_w, stop_w, wstop_w) = (Arc::clone(&rt), Arc::clone(&stop), Arc::clone(&wstop));
7419 if let Ok(h) = thread::Builder::new()
7420 .name(String::from("zdds-recv-shm-c"))
7421 .spawn(move || shm_consumer_recv_loop(rt_w, consumer, stop_w, wstop_w))
7422 {
7423 workers.insert(key, (wstop, h));
7424 }
7425 }
7426 // Reap workers whose consumer disappeared.
7427 let gone: Vec<[u8; 16]> = workers
7428 .keys()
7429 .filter(|k| !live.contains(*k))
7430 .copied()
7431 .collect();
7432 for k in gone {
7433 if let Some((wstop, h)) = workers.remove(&k) {
7434 wstop.store(true, Ordering::Relaxed);
7435 let _ = h.join();
7436 }
7437 }
7438 thread::sleep(membership_poll);
7439 }
7440 // Shutdown: stop + join every worker (each wakes within its 1 ms recv_timeout).
7441 for (_, (wstop, h)) in workers {
7442 wstop.store(true, Ordering::Relaxed);
7443 let _ = h.join();
7444 }
7445}
7446
7447/// One per-consumer SHM receive thread: blocks on this segment's futex and
7448/// dispatches each frame the instant it arrives — no cross-consumer
7449/// serialization. Exits when the runtime stops, the worker is reaped, or the
7450/// segment dies. See [`recv_user_shm_loop`].
7451#[cfg(feature = "same-host-shm")]
7452fn shm_consumer_recv_loop(
7453 rt: Arc<DcpsRuntime>,
7454 consumer: Arc<zerodds_transport_shm::PosixShmTransport>,
7455 stop: Arc<AtomicBool>,
7456 wstop: Arc<AtomicBool>,
7457) {
7458 use zerodds_transport::Transport;
7459 apply_thread_tuning(
7460 "recv-shm-c",
7461 rt.config.recv_thread_priority,
7462 rt.config.recv_thread_cpus.as_deref(),
7463 );
7464 while !stop.load(Ordering::Relaxed) && !wstop.load(Ordering::Relaxed) {
7465 match consumer.recv() {
7466 Ok(dg) => {
7467 let elapsed = rt.start_instant.elapsed();
7468 let sedp_now = Duration::from_secs(elapsed.as_secs())
7469 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7470 // Security gate (analogous to the UDP path). SHM is
7471 // same-host-only — if the policy allows plaintext, the
7472 // datagram comes through unchanged.
7473 #[cfg(feature = "security")]
7474 let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7475 #[cfg(not(feature = "security"))]
7476 let clear = secure_inbound_bytes(&rt, &dg.data);
7477 if let Some(clear) = clear {
7478 handle_user_datagram(&rt, &clear, sedp_now);
7479 }
7480 }
7481 // A timeout is normal — the 1 ms recv_timeout just lets the loop
7482 // re-check the stop flags; an empty segment is not an error.
7483 Err(zerodds_transport::RecvError::Timeout) => {}
7484 // Hard error (broken segment / peer crashed): drop this worker; the
7485 // supervisor respawns if the segment is re-bound, UDP stays fallback.
7486 Err(_) => break,
7487 }
7488 }
7489}
7490
7491/// Worker: blocks on the user-data unicast socket, dispatches
7492/// TypeLookup service replies + user-sample datagrams.
7493///
7494/// Int-1 (Spec `zerodds-zero-copy-1.0` §9): with the feature
7495/// `recvmmsg-batch` on Linux the loop uses `recv_batch_linux` and
7496/// fetches up to 32 datagrams per syscall — a 7-8x throughput boost.
7497/// On an empty batch the path falls back to single-recv() so
7498/// the recv thread does not spin in a busy loop at low traffic.
7499fn recv_user_data_loop(
7500 rt: Arc<DcpsRuntime>,
7501 socket: Arc<dyn Transport + Send + Sync>,
7502 stop: Arc<AtomicBool>,
7503) {
7504 apply_thread_tuning(
7505 "recv-user",
7506 rt.config.recv_thread_priority,
7507 rt.config.recv_thread_cpus.as_deref(),
7508 );
7509 // recvmmsg-batch (Linux + feature) needs the concrete UdpSocket
7510 // under the trait. With a trait-object transport this is not directly
7511 // accessible — we fall back to single-recv(). recvmmsg is
7512 // a UDP optimization; once TCP/SHM transports are to be mixed,
7513 // it is no longer worth it. For a pure UDPv4 user transport
7514 // this costs ~5-10% throughput in Linux batch mode (measured 2026-05).
7515 while !stop.load(Ordering::Relaxed) {
7516 let elapsed = rt.start_instant.elapsed();
7517 let sedp_now = Duration::from_secs(elapsed.as_secs())
7518 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
7519 let Ok(dg) = socket.recv() else {
7520 continue;
7521 };
7522 dispatch_user_datagram(&rt, &dg, sedp_now);
7523 // D.5e Phase 3 — incoming user data may solicit an ACKNACK or advance a
7524 // reliable reader: wake the scheduler tick immediately (no 5 ms tail).
7525 rt.raise_tick_wake();
7526 }
7527}
7528
7529/// Helper: dispatches a single user datagram through the security gate +
7530/// TypeLookup + handle_user_datagram. Shared by the single-recv and the
7531/// recvmmsg batch path.
7532fn dispatch_user_datagram(
7533 rt: &Arc<DcpsRuntime>,
7534 dg: &zerodds_transport::ReceivedDatagram,
7535 sedp_now: Duration,
7536) {
7537 #[cfg(feature = "security")]
7538 let clear = secure_inbound_bytes(rt, &dg.data, &DEFAULT_INBOUND_IFACE);
7539 #[cfg(not(feature = "security"))]
7540 let clear = secure_inbound_bytes(rt, &dg.data);
7541 if let Some(clear) = clear {
7542 // TypeLookup service first — if the frame is addressed to
7543 // TL_SVC_*_READER, it does not go to a
7544 // user reader. Other frames fall through.
7545 if !dispatch_type_lookup_datagram(rt, &clear, &dg.source) {
7546 handle_user_datagram(rt, &clear, sedp_now);
7547 }
7548 }
7549}
7550
7551/// Worker: periodic outbound tasks + per-interface inbound
7552/// (non-blocking) + housekeeping. Sleeps `tick_period` between
7553/// iterations.
7554fn tick_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
7555 apply_thread_tuning(
7556 "tick",
7557 rt.config.tick_thread_priority,
7558 rt.config.tick_thread_cpus.as_deref(),
7559 );
7560 let mut st = TickState::new(&rt);
7561 while !stop.load(Ordering::Relaxed) {
7562 run_tick_iteration(Arc::clone(&rt), &mut st);
7563 // Housekeeping runs inline here in the classic fixed-period path,
7564 // exactly as before (every `tick_period`, same cadence).
7565 tick_housekeep(&rt, rt.start_instant.elapsed());
7566 std::thread::sleep(rt.config.tick_period);
7567 }
7568}
7569
7570/// D.5e Phase 3 — idle park cap for a discovery-only participant (no user
7571/// endpoints): how long the scheduler tick worker may sleep when nothing but
7572/// SPDP/WLP is pending. SPDP/WLP fire on their own (longer) periods, so this is
7573/// just a safety heartbeat — well above the 5 ms poll it replaces.
7574const SCHEDULER_IDLE_FLOOR: Duration = Duration::from_millis(250);
7575
7576/// Earliest instant the scheduler tick worker must next run `run_tick_iteration`
7577/// so no periodic work is delayed: never past the next SPDP announce, and —
7578/// while user endpoints exist — capped at `tick_period` so HEARTBEAT/ACKNACK/
7579/// deadline/lifespan/liveliness keep their current cadence (identical wire
7580/// behaviour). With no user endpoints, parks up to [`SCHEDULER_IDLE_FLOOR`].
7581/// Active traffic is handled out-of-band by `raise_tick_wake` (immediate).
7582fn next_tick_deadline(rt: &Arc<DcpsRuntime>, st: &TickState) -> Instant {
7583 let now = Instant::now();
7584 let fine_cap = if rt.has_user_endpoints() {
7585 rt.config.tick_period
7586 } else {
7587 SCHEDULER_IDLE_FLOOR
7588 };
7589 st.next_announce.min(now + fine_cap).max(now)
7590}
7591
7592/// D.5e Phase 3 B-2 — the kinds of work the deadline-heap scheduler fires as
7593/// distinct heap events, each re-armed at its own next deadline.
7594#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7595enum TickEvent {
7596 /// Periodic SPDP announce + reliable outbound (SEDP / WLP / user HEARTBEAT /
7597 /// ACKNACK) + secondary inbound poll — the wire-producing tick
7598 /// ([`run_tick_iteration`]), re-armed at [`next_tick_deadline`].
7599 Tick,
7600 /// Deadline / lifespan / liveliness housekeeping ([`tick_housekeep`]),
7601 /// re-armed at the **exact** next QoS due-instant (no fixed quantum).
7602 Housekeep,
7603}
7604
7605/// D.5e Phase 3 — event-driven scheduler tick worker. Replaces the fixed-period
7606/// `tick_loop` sleep with a deadline-heap park. Two independent event streams:
7607/// [`TickEvent::Tick`] drives the **unchanged** `run_tick_iteration` (wire
7608/// output byte-identical to `tick_loop`), re-armed at [`next_tick_deadline`];
7609/// [`TickEvent::Housekeep`] runs the QoS checks, re-armed at their exact next
7610/// due-instant so a deadline/lifespan/liveliness fires on time instead of up to
7611/// one `tick_period` late, and an idle participant parks long. A write/recv
7612/// `raise_tick_wake` wakes **both** immediately, so freshly-armed QoS windows
7613/// are picked up without delay.
7614fn scheduler_tick_loop(
7615 rt: Arc<DcpsRuntime>,
7616 stop: Arc<AtomicBool>,
7617 mut scheduler: crate::scheduler::Scheduler<TickEvent>,
7618 handle: crate::scheduler::SchedulerHandle<TickEvent>,
7619) {
7620 apply_thread_tuning(
7621 "tick",
7622 rt.config.tick_thread_priority,
7623 rt.config.tick_thread_cpus.as_deref(),
7624 );
7625 let mut st = TickState::new(&rt);
7626 // Prime both event streams immediately.
7627 handle.raise_now(TickEvent::Tick);
7628 handle.raise_now(TickEvent::Housekeep);
7629 loop {
7630 let (due, stopped) = scheduler.park_due_batch();
7631 if stopped || stop.load(Ordering::Relaxed) {
7632 break;
7633 }
7634 if due.is_empty() {
7635 continue; // woken with nothing due yet — re-evaluate.
7636 }
7637 // Coalesce: a batch of wakes maps to at most ONE run of each kind.
7638 let mut do_tick = false;
7639 let mut do_housekeep = false;
7640 for ev in due {
7641 match ev {
7642 TickEvent::Tick => do_tick = true,
7643 TickEvent::Housekeep => do_housekeep = true,
7644 }
7645 }
7646 if do_tick {
7647 rt.tick_wake_pending.store(false, Ordering::Release);
7648 run_tick_iteration(Arc::clone(&rt), &mut st);
7649 if stop.load(Ordering::Relaxed) {
7650 break;
7651 }
7652 handle.raise_at(next_tick_deadline(&rt, &st), TickEvent::Tick);
7653 }
7654 if do_housekeep {
7655 let next = tick_housekeep(&rt, rt.start_instant.elapsed());
7656 if stop.load(Ordering::Relaxed) {
7657 break;
7658 }
7659 // Park exactly until the next QoS due-instant; nothing pending →
7660 // idle floor (a later write re-arms via `raise_tick_wake`).
7661 let deadline = match next {
7662 Some(due_nanos) => rt.start_instant + Duration::from_nanos(due_nanos),
7663 None => Instant::now() + SCHEDULER_IDLE_FLOOR,
7664 };
7665 handle.raise_at(deadline, TickEvent::Housekeep);
7666 }
7667 }
7668}
7669
7670/// Per-iteration mutable state of the runtime tick. Held across iterations so
7671/// the same body ([`run_tick_iteration`]) can be driven from either the
7672/// dedicated `zdds-tick` thread (default) or an external executor — tokio via
7673/// [`DcpsRuntime::tick_driver`] / async `spawn_in_tokio`
7674/// (zerodds-async-1.0 §4).
7675struct TickState {
7676 /// Multicast target locator to which we send SPDP beacons.
7677 mc_target: Locator,
7678 /// Next instant at which a periodic SPDP announce is due.
7679 next_announce: Instant,
7680 /// Number of SPDP announces already sent. Drives the C3 initial
7681 /// announcement burst: as long as `< initial_announce_count` **and** no
7682 /// peer discovered yet, announces happen at `initial_announce_period` cadence
7683 /// instead of the full `spdp_period` — so discovery over lossy/power-save WiFi
7684 /// does not fail on lost first beacons.
7685 announces_done: u32,
7686 /// FastDDS interop: count for the periodic secure-SPDP HEARTBEATs
7687 /// (0xff0101c2). Must increase, otherwise FastDDS' reader ignores follow-up HBs.
7688 #[cfg(feature = "security")]
7689 secure_hb_count: i32,
7690}
7691
7692impl TickState {
7693 fn new(rt: &Arc<DcpsRuntime>) -> Self {
7694 let mc_target = Locator {
7695 kind: LocatorKind::UdpV4,
7696 port: u32::from(
7697 u16::try_from(spdp_multicast_port(rt.domain_id as u32)).unwrap_or(7400),
7698 ),
7699 address: {
7700 let mut a = [0u8; 16];
7701 a[12..].copy_from_slice(&rt.config.spdp_multicast_group.octets());
7702 a
7703 },
7704 };
7705 Self {
7706 mc_target,
7707 next_announce: Instant::now(), // immediately at start
7708 announces_done: 0,
7709 #[cfg(feature = "security")]
7710 secure_hb_count: 0,
7711 }
7712 }
7713}
7714
7715/// One iteration of the runtime's **wire** tick: periodic SPDP announce,
7716/// SEDP/WLP ticks, per-user-writer + per-user-reader ticks, secondary inbound
7717/// poll. QoS housekeeping (deadline/lifespan/liveliness) is **not** part of this
7718/// — each driver calls [`tick_housekeep`] separately (D.5e Phase 3 B-2), so the
7719/// event-driven scheduler can fire it on its own exact-deadline schedule.
7720/// Mutable per-iteration state lives in `st`; the caller waits `tick_period`
7721/// between calls. Factored out of [`tick_loop`] so an external executor can
7722/// drive the tick without the dedicated thread (zerodds-async-1.0 §4).
7723fn run_tick_iteration(rt: Arc<DcpsRuntime>, st: &mut TickState) {
7724 // Monotonic clock relative to runtime start. Used by the SEDP,
7725 // WLP and user tick alike.
7726 let elapsed_since_start = rt.start_instant.elapsed();
7727 let sedp_now = Duration::from_secs(elapsed_since_start.as_secs())
7728 + Duration::from_nanos(u64::from(elapsed_since_start.subsec_nanos()));
7729
7730 // --- Periodic SPDP announce ---
7731 // FU2 cross-vendor (cyclone-trace-documented): a secured participant MUST
7732 // NOT announce before its security builtins are enabled — otherwise
7733 // a token-less/non-secure first beacon goes out, which foreign vendors
7734 // (cyclone: "Non secure remote ... not allowed by security") latch as
7735 // non-secure and, on the later token beacon, treat ONLY as a QoS update
7736 // (no security re-evaluation) → the handshake never starts.
7737 // `config.security.is_some()` = secured runtime; until
7738 // `enable_security_builtins*` installs the stack (snapshot Some) +
7739 // sets the token/security-info on the beacon, we hold the beacon
7740 // back. enable() triggers the first token-carrying beacon via
7741 // `announce_spdp_now()`. Plain runtimes (security None) announce
7742 // immediately as before.
7743 #[cfg(feature = "security")]
7744 let security_pending = rt.config.security.is_some() && rt.security_builtin_snapshot().is_none();
7745 #[cfg(not(feature = "security"))]
7746 let security_pending = false;
7747 if Instant::now() >= st.next_announce && !security_pending {
7748 let secured_beacon: Option<Vec<u8>> = {
7749 if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7750 beacon
7751 .serialize()
7752 .ok()
7753 .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7754 } else {
7755 None
7756 }
7757 };
7758 if let Some(secured) = secured_beacon {
7759 let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7760 // C1 multicast-free discovery: additionally to all configured
7761 // initial peers (ZERODDS_PEERS) — bootstrap without multicast.
7762 rt.send_spdp_to_initial_peers(&secured);
7763 // SPDP unicast fan-out to discovered peers (analogous to WLP-M-2/H-3-H-4):
7764 // codepit-LXC multicast is flaky; if it loses the tokened
7765 // secure beacon, the peer never discovers ZeroDDS as secure and
7766 // NEVER starts the auth handshake (cyclone→ZeroDDS responder hung
7767 // exactly here: HS_DISPATCH=0). From the metatraffic recv socket
7768 // (spdp_unicast), so the source port is correct.
7769 // Periodic directed unicast fan-out to discovered peers:
7770 // codepit-LXC multicast is flaky; if it loses the tokened
7771 // beacon, the peer never discovers ZeroDDS as secure and never starts
7772 // the auth handshake. The unicast refresh (every spdp_period) robustly
7773 // covers lost multicasts + late joiners. (Previously disabled for a
7774 // flaky-diag experiment — reactivated as a regular path,
7775 // complements the event-driven directed response in handle_spdp_datagram.)
7776 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7777 let _ = rt.spdp_unicast.send(&loc, &secured);
7778 }
7779 }
7780 // FastDDS interop: announce in parallel on the reliable secure-SPDP writer
7781 // (0xff0101c2). FastDDS announces its full secured
7782 // participant data over this channel and gates the crypto-token
7783 // reciprocation on it; without our secure SPDP it never sees ZeroDDS there
7784 // and reciprocates no datawriter/datareader tokens.
7785 #[cfg(feature = "security")]
7786 if rt.config.enable_secure_spdp {
7787 let secure_beacon: Option<Vec<u8>> = {
7788 if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7789 beacon
7790 .serialize_secure()
7791 .ok()
7792 .and_then(|d| protect_secure_spdp(&rt, &d))
7793 .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7794 } else {
7795 None
7796 }
7797 };
7798 if let Some(secured) = secure_beacon {
7799 let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7800 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7801 let _ = rt.spdp_unicast.send(&loc, &secured);
7802 }
7803 }
7804 // Secure-SPDP HEARTBEAT per peer (INFO_DST), so FastDDS' reader
7805 // — even as a late joiner — is solicited to a (preemptive) ACKNACK
7806 // and matches our 0xff0101c2 writer. Without a HEARTBEAT
7807 // FastDDS does not engage our writer (fastdds->zerodds: 0 ACKNACK).
7808 st.secure_hb_count = st.secure_hb_count.wrapping_add(1);
7809 for p in rt.discovered_participants() {
7810 let peer_prefix = p.data.guid.prefix;
7811 if let Some(hb) =
7812 build_secure_spdp_heartbeat(rt.guid_prefix, peer_prefix, st.secure_hb_count)
7813 {
7814 for loc in wlp_unicast_targets(core::slice::from_ref(&p)) {
7815 let _ = rt.spdp_unicast.send(&loc, &hb);
7816 }
7817 }
7818 }
7819 }
7820 // C3 WiFi robustness — initial announcement burst: as long as we have
7821 // not discovered a peer yet and the burst count is not exhausted,
7822 // announce at the fast `initial_announce_period` cadence. Over
7823 // lossy/power-save WiFi the first beacons often get lost in the cold-start
7824 // or sleep window; a single announce + 5s period
7825 // then leads to `participants=0`. The burst keeps the NIC awake through
7826 // frequent TX, keeps the stateful-firewall pinhole open and
7827 // elicits directed SPDP responses that arrive in the wake windows
7828 // — analogous to FastDDS `initial_announcements`. As soon as a peer
7829 // is discovered, the cadence falls back to the full `spdp_period`.
7830 st.announces_done = st.announces_done.saturating_add(1);
7831 rt.spdp_announce_seq.fetch_add(1, Ordering::Relaxed);
7832 let still_searching = st.announces_done < rt.config.initial_announce_count
7833 && rt.discovered_participants().is_empty();
7834 let period = if still_searching {
7835 rt.config.initial_announce_period
7836 } else {
7837 rt.config.spdp_period
7838 };
7839 st.next_announce = Instant::now() + period;
7840 }
7841
7842 // (SPDP multicast recv: now in `recv_spdp_multicast_loop`.)
7843
7844 // --- SEDP-Tick (outbound HEARTBEAT/Resend/ACKNACK) ---
7845 let sedp_outbound = {
7846 if let Ok(mut sedp) = rt.sedp.lock() {
7847 sedp.tick(sedp_now).unwrap_or_default()
7848 } else {
7849 Vec::new()
7850 }
7851 };
7852 for dg in sedp_outbound {
7853 // Protected discovery: SEC_*-protect secure-SEDP DATA/HEARTBEAT/GAP
7854 // (participant data key). Non-secure SEDP goes unchanged; on a
7855 // crypto error on secure SEDP it is dropped (no plaintext leak).
7856 #[cfg(feature = "security")]
7857 {
7858 if let Some(inner) = protect_sedp_outbound(&rt, &dg.bytes) {
7859 // discovery_protection has SEC-wrapped the secure SEDP per-submessage
7860 // (SEC_PREFIX/BODY/POSTFIX, per-endpoint key). Under
7861 // rtps_protection SRTPS MUST additionally go on top — BOTH layers,
7862 // like cyclone<->cyclone (reference pcap: 0x "clear submsg from
7863 // protected src"). send_discovery_datagram -> secure_outbound_bytes
7864 // would classify the SEC_PREFIX datagram as volatile-Kx (which is
7865 // RIGHTLY SRTPS-exempt, because its key only comes over the volatile
7866 // itself) and skip SRTPS -> cyclone would see the
7867 // secure SEDP clear, discard ACKNACK/HEARTBEAT as "clear submsg
7868 // from protected src" and never re-send the SubscriptionData ->
7869 // ZeroDDS' writer never matches cyclone's reader (wait_for_matched
7870 // timeout). Hence wrap SRTPS EXPLICITLY here instead of via the
7871 // generic exempt heuristic.
7872 let final_bytes: Option<Vec<u8>> = match &rt.config.security {
7873 Some(gate)
7874 if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
7875 != ProtectionLevel::None =>
7876 {
7877 gate.transform_outbound(&inner).ok()
7878 }
7879 _ => Some(inner),
7880 };
7881 if let Some(fb) = final_bytes {
7882 for t in dg.targets.iter() {
7883 if is_routable_user_locator(t) {
7884 let _ = rt.spdp_unicast.send(t, &fb);
7885 }
7886 }
7887 }
7888 }
7889 }
7890 #[cfg(not(feature = "security"))]
7891 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7892 }
7893
7894 // --- Security-Builtin-Tick ---
7895 // Volatile-Secure-Writer heartbeats + Volatile-Secure-Reader
7896 // ACKNACK/NACK_FRAG. Stateless hat keinen Tick (BestEffort).
7897 if let Some(stack) = rt.security_builtin_snapshot() {
7898 let outbound = {
7899 if let Ok(mut s) = stack.lock() {
7900 // `out` is only mutated under feature="security" (reassign +
7901 // extend in the cfg block below); otherwise unused_mut in the no-security build.
7902 #[allow(unused_mut)]
7903 let mut out = s.poll(sedp_now).unwrap_or_default();
7904 #[cfg(feature = "security")]
7905 if rt.config.security.is_some() {
7906 // STABLE peer list: `completed_peer_prefixes()` reads
7907 // `self.handshakes`, which is GC'd after handshake completion
7908 // → the LATE volatile RESENDS/HEARTBEATs (tick, long after
7909 // completion) would then find NO peer anymore (`peers.len()!=1`)
7910 // and go out CLEAR → cyclone discards them as "clear
7911 // submsg from protected src". The stabler `authenticated_peer_
7912 // prefixes()` (the installed Kx key stays) — identical to the
7913 // token-send tick further below.
7914 let peers: Vec<GuidPrefix> = rt
7915 .config
7916 .security
7917 .as_ref()
7918 .map(|g| {
7919 g.authenticated_peer_prefixes()
7920 .into_iter()
7921 .map(GuidPrefix::from_bytes)
7922 .collect()
7923 })
7924 .unwrap_or_default();
7925 // The reliable volatile submessages from poll() (DATA RESENDS
7926 // + HEARTBEAT + GAP) must — like the first send — be SEC_*-
7927 // protected (§8.4.2.4, all writer submessages incl.
7928 // HEARTBEAT). protect_volatile_datagram now protects all
7929 // is_protected_writer_submessage. With exactly one peer
7930 // (bench) with its Kx key.
7931 if peers.len() == 1 {
7932 let pk = peers[0].to_bytes();
7933 out = out
7934 .into_iter()
7935 .filter_map(|dg| {
7936 protect_volatile_datagram(&rt, &dg.bytes, &pk).map(|bytes| {
7937 zerodds_rtps::message_builder::OutboundDatagram {
7938 bytes,
7939 targets: dg.targets,
7940 }
7941 })
7942 })
7943 .collect();
7944 }
7945 // FU2 step 6b: send per-endpoint datawriter/datareader crypto
7946 // tokens to every authenticated peer as soon as the
7947 // local user endpoints exist.
7948 //
7949 // STABLE peer list instead of `completed_peer_prefixes()`: the
7950 // handshake entry is GC'd after completion, so a
7951 // late-matching user writer/reader (user endpoints match
7952 // AFTER the secure SEDP) would find no tick window in which
7953 // its per-endpoint token would go out — the peer could then never
7954 // decode ZeroDDS' user DATA (#29). `authenticated_peer_
7955 // prefixes()` (the installed data key) stays.
7956 let token_peers: Vec<GuidPrefix> = rt
7957 .config
7958 .security
7959 .as_ref()
7960 .map(|g| {
7961 g.authenticated_peer_prefixes()
7962 .into_iter()
7963 .map(GuidPrefix::from_bytes)
7964 .collect()
7965 })
7966 .unwrap_or_default();
7967 for prefix in token_peers {
7968 // Per-token dedup (#29): each per-endpoint token
7969 // exactly once — builtins early, user endpoints
7970 // as soon as they match. A per-peer guard would
7971 // block late-matched user endpoints forever.
7972 let already = rt
7973 .endpoint_tokens_sent
7974 .read()
7975 .map(|set| set.clone())
7976 .unwrap_or_default();
7977 let pending = pending_endpoint_tokens(
7978 prepare_endpoint_crypto_tokens(&rt, prefix),
7979 &already,
7980 );
7981 for ep_msg in pending {
7982 let key = endpoint_token_key(&ep_msg);
7983 out.extend(protect_volatile_outbound(
7984 &rt,
7985 prefix,
7986 s.volatile_writer
7987 .write_with_heartbeat(&ep_msg, sedp_now)
7988 .unwrap_or_default(),
7989 ));
7990 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
7991 set.insert(key);
7992 }
7993 }
7994 }
7995 }
7996 out
7997 } else {
7998 Vec::new()
7999 }
8000 };
8001 for dg in outbound {
8002 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
8003 }
8004 }
8005
8006 // --- WLP-Tick (Writer-Liveliness-Protocol Heartbeats) ---
8007 //
8008 // RTPS 2.5 §8.4.13: WLP heartbeats are metatraffic.
8009 // Spec recommendation: multicast to all known peers, one
8010 // heartbeat per `lease_duration / 3`. We send via the
8011 // SPDP multicast sender — that is the same socket that
8012 // sends out the SPDP beacons, and it ensures that all
8013 // peers see the WLP pulses without the runtime having to
8014 // look up a unicast locator per peer.
8015 let wlp_outbound = {
8016 if let Ok(mut wlp) = rt.wlp.lock() {
8017 // Use the secure-WLP entity when liveliness_protection != NONE
8018 // (set idempotently per tick — follows the current governance).
8019 wlp.set_secure(wlp_liveliness_protected(&rt));
8020 wlp.tick(sedp_now).unwrap_or(None)
8021 } else {
8022 None
8023 }
8024 };
8025 if let Some(bytes) = wlp_outbound {
8026 // Under liveliness_protection != NONE the secure-WLP DATA is protected
8027 // with the participant key (§8.4.2.4); otherwise rtps-level/plaintext.
8028 if let Some(secured) = protect_wlp_outbound(&rt, &bytes) {
8029 // Multicast to all peers (spec recommendation §8.4.13)...
8030 let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
8031 // ...plus unicast to every discovered peer (M-2), so WLP also
8032 // arrives without multicast (container/cloud). From the metatraffic recv
8033 // socket (spdp_unicast), so the source port is correct (cf. H-3/H-4).
8034 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
8035 let _ = rt.spdp_unicast.send(&loc, &secured);
8036 }
8037 }
8038 }
8039
8040 // (Metatraffic unicast recv: now in `recv_metatraffic_loop`.)
8041
8042 // --- User-Writer-Tick (HEARTBEAT + Resends) ---
8043 //
8044 // Security: per-target serializer. A datagram can go to
8045 // multiple reader locators. Per target we pull it
8046 // individually through `secure_outbound_for_target`, so the
8047 // wire payload matches the protection class of the respective reader.
8048 let user_writer_outbound: Vec<(EntityId, _)> = {
8049 let mut all = Vec::new();
8050 for (eid, arc) in rt.writer_slots_snapshot() {
8051 if let Ok(mut slot) = arc.lock() {
8052 if let Ok(dgs) = slot.writer.tick(sedp_now) {
8053 for dg in dgs {
8054 all.push((eid, dg));
8055 }
8056 }
8057 }
8058 }
8059 all
8060 };
8061 for (writer_eid, dg) in user_writer_outbound {
8062 for t in dg.targets.iter() {
8063 if !is_routable_user_locator(t) {
8064 continue;
8065 }
8066 if let Some(secured) = secure_outbound_for_target(&rt, writer_eid, &dg.bytes, t) {
8067 send_on_best_interface(&rt, t, &secured);
8068 }
8069 }
8070 }
8071
8072 // --- User-Reader-Tick-Outbound (ACKNACK / NACK_FRAG) ---
8073 let user_reader_outbound: Vec<_> = {
8074 let mut all = Vec::new();
8075 for (_eid, arc) in rt.reader_slots_snapshot() {
8076 if let Ok(mut slot) = arc.lock() {
8077 if let Ok(dgs) = slot.reader.tick_outbound(sedp_now) {
8078 all.extend(dgs);
8079 }
8080 }
8081 }
8082 all
8083 };
8084 for dg in user_reader_outbound {
8085 if let Some(secured) = protect_user_reader_datagram(&rt, &dg.bytes) {
8086 for t in dg.targets.iter() {
8087 if is_routable_user_locator(t) {
8088 let _ = rt.user_unicast.send(t, &secured);
8089 }
8090 }
8091 }
8092 }
8093
8094 // (User-data unicast recv: now in `recv_user_data_loop`.)
8095
8096 // --- Per-interface inbound ---
8097 //
8098 // Each pool binding is polled non-blocking; the
8099 // received datagram goes through `secure_inbound_bytes` with
8100 // the matching NetInterface class. This lets the
8101 // PolicyEngine make interface-specific decisions
8102 // (e.g. accept loopback-plain on a protected domain).
8103 //
8104 // The non-blocking semantics are achieved by each socket
8105 // in `bind_all` holding a short read timeout — see
8106 // `OutboundSocketPool::bind_all`. Without a timeout the
8107 // event loop would hang on an empty binding per tick.
8108 #[cfg(feature = "security")]
8109 if let Some(pool) = &rt.outbound_pool {
8110 for binding in &pool.bindings {
8111 while let Ok(dg) = binding.socket.recv() {
8112 let iface = binding.spec.kind.clone();
8113 if let Some(clear) = secure_inbound_bytes(&rt, &dg.data, &iface) {
8114 // Try SPDP first (reverse beacons), then
8115 // SEDP, then user data — same dispatch as
8116 // for the legacy sockets.
8117 handle_spdp_datagram(&rt, &clear);
8118 let events = rt
8119 .sedp
8120 .lock()
8121 .ok()
8122 .and_then(|mut s| s.handle_datagram(&clear, sedp_now).ok());
8123 if let Some(ev) = events {
8124 if !ev.is_empty() {
8125 run_matching_pass(&rt);
8126 push_sedp_events_to_builtin_readers(&rt, &ev);
8127 }
8128 }
8129 if !dispatch_type_lookup_datagram(&rt, &clear, &dg.source) {
8130 handle_user_datagram(&rt, &clear, sedp_now);
8131 }
8132 // DDS-Security 1.2 §7.4.2 Builtin-Endpoints
8133 for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
8134 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
8135 }
8136 }
8137 }
8138 }
8139 }
8140
8141 // Housekeeping (deadline/lifespan/liveliness) runs as a separate
8142 // `tick_housekeep` call of the respective driver (tick_loop /
8143 // tick_driver / scheduler_tick_loop) — see `tick_housekeep`.
8144
8145 // Diagnostic: mark this iteration complete so `tick_count()` advances
8146 // whether driven by the internal thread or an external executor.
8147 rt.tick_seq.fetch_add(1, Ordering::Relaxed);
8148}
8149
8150/// Min tracker for the earliest "next-due" instant (nanos in the runtime
8151/// `elapsed` time base) across multiple housekeeping sources.
8152struct NextDue(Option<u64>);
8153
8154impl NextDue {
8155 fn new() -> Self {
8156 Self(None)
8157 }
8158 fn note(&mut self, due_nanos: u64) {
8159 self.0 = Some(self.0.map_or(due_nanos, |e| e.min(due_nanos)));
8160 }
8161 fn into_inner(self) -> Option<u64> {
8162 self.0
8163 }
8164}
8165
8166/// D.5e Phase 3 B-2 — the time-driven housekeeping checks, factored out of
8167/// [`run_tick_iteration`], so the event-driven scheduler can fire them
8168/// as its own [`TickEvent::Housekeep`] heap event exactly at the next
8169/// due-instant (and `tick_loop`/`tick_driver` call them inline).
8170/// Pure reader/writer-side bookkeeping — **no** cross-vendor wire
8171/// output, the cadence is purely internal.
8172///
8173/// Return value: the earliest instant (nanos in the `elapsed` time base) at which
8174/// a check is due again, or `None` if nothing is currently pending
8175/// (no active deadline/lifespan/liveliness slot) — then the
8176/// scheduler parks until the idle floor resp. until a `raise_tick_wake` signals new
8177/// work.
8178fn tick_housekeep(rt: &Arc<DcpsRuntime>, elapsed: Duration) -> Option<u64> {
8179 let mut next_due = NextDue::new();
8180 // --- Deadline-Monitoring ---
8181 if let Some(d) = check_deadlines(rt, elapsed) {
8182 next_due.note(d);
8183 }
8184 // --- Lifespan-Expire ---
8185 if let Some(d) = expire_by_lifespan(rt, elapsed) {
8186 next_due.note(d);
8187 }
8188 // --- Liveliness lease check (reader side) ---
8189 if let Some(d) = check_liveliness(rt, elapsed) {
8190 next_due.note(d);
8191 }
8192 // --- Writer-side liveliness-lost check ---
8193 if let Some(d) = check_writer_liveliness(rt, elapsed) {
8194 next_due.note(d);
8195 }
8196 next_due.into_inner()
8197}
8198
8199impl DcpsRuntime {
8200 /// Number of completed tick iterations since `start()`. Advances once per
8201 /// tick regardless of whether the internal `zdds-tick` thread or an
8202 /// external executor ([`DcpsRuntime::tick_driver`]) drives it — a stalled
8203 /// value means the periodic tick stopped. Diagnostic only.
8204 #[must_use]
8205 pub fn tick_count(&self) -> u64 {
8206 self.tick_seq.load(Ordering::Relaxed)
8207 }
8208
8209 /// Number of SPDP announces emitted since `start()`. Diagnostic for the C3
8210 /// initial-announcement burst: a fresh participant with no discovered peer
8211 /// advances this at [`RuntimeConfig::initial_announce_period`] for the first
8212 /// [`RuntimeConfig::initial_announce_count`] announces, then slows to
8213 /// `spdp_period`.
8214 #[must_use]
8215 pub fn spdp_announce_count(&self) -> u64 {
8216 self.spdp_announce_seq.load(Ordering::Relaxed)
8217 }
8218
8219 /// Number of discovered topic inconsistencies (DDS 1.4 §2.2.4.2.4).
8220 /// Bumped during matching against the SEDP cache whenever a remote
8221 /// endpoint carries the same `topic_name` but a differing `type_name`
8222 /// than a local endpoint. A delta against the last poll snapshot
8223 /// triggers `on_inconsistent_topic`.
8224 #[must_use]
8225 pub fn inconsistent_topic_count(&self) -> u64 {
8226 self.inconsistent_topic_seq.load(Ordering::Relaxed)
8227 }
8228
8229 /// External tick driver (zerodds-async-1.0 §4). Only meaningful when the
8230 /// runtime was started with [`RuntimeConfig::external_tick`] = `true`,
8231 /// which suppresses the dedicated `zdds-tick` thread. Each
8232 /// [`DcpsTickDriver::tick`] call runs exactly one tick iteration; the
8233 /// caller schedules the next after [`DcpsTickDriver::tick_period`]. The
8234 /// async API's `spawn_in_tokio` uses this to multiplex many participants'
8235 /// tick loops onto a tokio runtime instead of one std::thread each.
8236 #[must_use]
8237 pub fn tick_driver(self: &Arc<Self>) -> DcpsTickDriver {
8238 DcpsTickDriver {
8239 st: TickState::new(self),
8240 rt: Arc::clone(self),
8241 }
8242 }
8243
8244 /// D.5e Phase 3 — wake the scheduler tick worker immediately (new work:
8245 /// a sample written, a HEARTBEAT/DATA/ACKNACK received). Coalesced: many
8246 /// raises between two worker passes collapse into a single wake, so a
8247 /// datagram storm does not flood the channel. No-op unless started with
8248 /// `scheduler_tick`.
8249 pub fn raise_tick_wake(&self) {
8250 // Only the first raiser since the last pass actually sends.
8251 if self.tick_wake_pending.swap(true, Ordering::AcqRel) {
8252 return;
8253 }
8254 if let Ok(guard) = self.tick_wake.lock() {
8255 if let Some(h) = guard.as_ref() {
8256 // Active traffic wakes the reliable tick AND re-evaluates
8257 // housekeeping, so a freshly-armed deadline/lifespan/liveliness
8258 // window is scheduled at once instead of waiting out the park.
8259 h.raise_now(TickEvent::Tick);
8260 h.raise_now(TickEvent::Housekeep);
8261 }
8262 }
8263 }
8264
8265 /// `true` if this participant has any user DataWriter or DataReader — i.e.
8266 /// the fine-grained periodic work (HEARTBEAT / ACKNACK / deadline / lifespan
8267 /// / liveliness) may be due and the scheduler keeps a fine cadence. A pure
8268 /// discovery-only participant parks long.
8269 fn has_user_endpoints(&self) -> bool {
8270 self.user_writers
8271 .read()
8272 .map(|m| !m.is_empty())
8273 .unwrap_or(true)
8274 || self
8275 .user_readers
8276 .read()
8277 .map(|m| !m.is_empty())
8278 .unwrap_or(true)
8279 }
8280}
8281
8282/// Drives a runtime's periodic tick from an external executor (tokio, an
8283/// embedded scheduler, a manual test loop). Obtained via
8284/// [`DcpsRuntime::tick_driver`]; only does useful work when the runtime was
8285/// started with [`RuntimeConfig::external_tick`] = `true`.
8286///
8287/// Typical loop (the async crate's `spawn_in_tokio` shape):
8288///
8289/// ```ignore
8290/// let mut driver = runtime.tick_driver();
8291/// let period = driver.tick_period();
8292/// while !driver.is_stopped() {
8293/// driver.tick();
8294/// tokio::time::sleep(period).await;
8295/// }
8296/// ```
8297pub struct DcpsTickDriver {
8298 rt: Arc<DcpsRuntime>,
8299 st: TickState,
8300}
8301
8302impl DcpsTickDriver {
8303 /// Period the caller should wait between consecutive [`Self::tick`] calls
8304 /// (mirrors the internal `zdds-tick` thread's `tick_period`).
8305 #[must_use]
8306 pub fn tick_period(&self) -> Duration {
8307 self.rt.config.tick_period
8308 }
8309
8310 /// `true` once the runtime is shutting down (set by `Drop`/`stop()`). The
8311 /// driving task must then stop calling [`Self::tick`] and return so the
8312 /// runtime can be dropped cleanly.
8313 #[must_use]
8314 pub fn is_stopped(&self) -> bool {
8315 self.rt.stop.load(Ordering::Relaxed)
8316 }
8317
8318 /// Run one tick iteration: periodic SPDP announce, SEDP/WLP ticks,
8319 /// per-user-writer ticks, deadline/lifespan/liveliness checks. Equivalent
8320 /// to one pass of the internal `zdds-tick` loop body.
8321 pub fn tick(&mut self) {
8322 run_tick_iteration(Arc::clone(&self.rt), &mut self.st);
8323 tick_housekeep(&self.rt, self.rt.start_instant.elapsed());
8324 }
8325}
8326
8327/// Writer-side liveliness-lost detection. Spec §2.2.4.2.10.
8328///
8329/// For all user writers: if a lease duration is set and more time
8330/// has elapsed since the last assert (Automatic = `last_write`, Manual =
8331/// `last_liveliness_assert`) than the
8332/// lease duration allows, the writer counts as
8333/// "not-alive" from the DDS view — `liveliness_lost_count++` and reset the window.
8334///
8335/// Note: with pure best-effort tests + `Automatic` the
8336/// counter typically does not advance — Automatic asserts with every
8337/// `write_user_sample`. Manual mode requires an explicit
8338/// `assert_liveliness` (comes with .4b — until then we already provide
8339/// the detection here, the hot-path trigger triggers it).
8340fn check_writer_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8341 let now_nanos = now.as_nanos() as u64;
8342 let mut next_due = NextDue::new();
8343 for (_eid, arc) in rt.writer_slots_snapshot() {
8344 let Ok(mut slot) = arc.lock() else { continue };
8345 if slot.liveliness_lease_nanos == 0 {
8346 continue;
8347 }
8348 let last = match slot.liveliness_kind {
8349 zerodds_qos::LivelinessKind::Automatic => slot.last_write,
8350 _ => slot.last_liveliness_assert,
8351 };
8352 let last_nanos = match last {
8353 Some(t) => t.as_nanos() as u64,
8354 None => continue,
8355 };
8356 if now_nanos.saturating_sub(last_nanos) >= slot.liveliness_lease_nanos {
8357 slot.liveliness_lost_count = slot.liveliness_lost_count.saturating_add(1);
8358 // Reset the window, so the same lease-window
8359 // overrun does not count in an infinite loop.
8360 // Spec §2.2.3.11: "lease has elapsed" — `>=` is boundary-
8361 // stable and avoids flakiness when tick_period == lease.
8362 slot.last_liveliness_assert = Some(now);
8363 slot.last_write = Some(now);
8364 next_due.note(now_nanos.saturating_add(slot.liveliness_lease_nanos));
8365 } else {
8366 next_due.note(last_nanos.saturating_add(slot.liveliness_lease_nanos));
8367 }
8368 }
8369 next_due.into_inner()
8370}
8371
8372/// Checks for all user readers whether the writer has delivered no sample
8373/// for longer than `lease_duration`. If so: transition
8374/// alive → not_alive, `not_alive_count++`.
8375///
8376/// Automatic liveliness (§2.2.3.11): every write is an implicit assert.
8377/// So we check the reader-side `last_sample_received`.
8378/// Manual kinds come with .4b (explicit assert messages).
8379fn check_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8380 let now_nanos = now.as_nanos() as u64;
8381 let mut next_due = NextDue::new();
8382 for (_eid, arc) in rt.reader_slots_snapshot() {
8383 let Ok(mut slot) = arc.lock() else { continue };
8384 if slot.liveliness_lease_nanos == 0 {
8385 continue;
8386 }
8387 // Until the first sample: consider it alive (optimistic).
8388 let last = match slot.last_sample_received {
8389 Some(t) => t.as_nanos() as u64,
8390 None => continue,
8391 };
8392 // Only a still-alive reader can transition; one already
8393 // not_alive stays so until a new sample arrives (event-driven
8394 // via the recv path) — so no re-schedule needed.
8395 if !slot.liveliness_alive {
8396 continue;
8397 }
8398 if now_nanos.saturating_sub(last) >= slot.liveliness_lease_nanos {
8399 slot.liveliness_alive = false;
8400 slot.liveliness_not_alive_count = slot.liveliness_not_alive_count.saturating_add(1);
8401 } else {
8402 next_due.note(last.saturating_add(slot.liveliness_lease_nanos));
8403 }
8404 }
8405 next_due.into_inner()
8406}
8407
8408/// For all user writers: remove samples from the HistoryCache whose
8409/// insert time + lifespan has elapsed. OMG DDS 1.4 §2.2.3.16:
8410/// "If the duration...elapses and the sample is still in the cache...
8411/// the sample is no longer available to any future DataReaders".
8412///
8413/// Implementation: `sample_insert_times` is a VecDeque, sorted
8414/// by insert time (= SN, because monotonic). Front-pop while expired;
8415/// the highest expired SN runs through via `cache.remove_up_to(sn + 1)`.
8416fn expire_by_lifespan(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8417 let now_nanos = now.as_nanos() as u64;
8418 let mut next_due = NextDue::new();
8419 for (_eid, arc) in rt.writer_slots_snapshot() {
8420 let Ok(mut slot) = arc.lock() else { continue };
8421 if slot.lifespan_nanos == 0 {
8422 continue;
8423 }
8424 let mut highest_expired = None;
8425 while let Some(&(sn, inserted)) = slot.sample_insert_times.front() {
8426 let inserted_nanos = inserted.as_nanos() as u64;
8427 if now_nanos.saturating_sub(inserted_nanos) >= slot.lifespan_nanos {
8428 highest_expired = Some(sn);
8429 slot.sample_insert_times.pop_front();
8430 } else {
8431 break;
8432 }
8433 }
8434 if let Some(sn) = highest_expired {
8435 let _removed = slot
8436 .writer
8437 .remove_samples_up_to(zerodds_rtps::wire_types::SequenceNumber(sn.0 + 1));
8438 }
8439 // Next lifespan due = expiry of the now-oldest sample still
8440 // remaining in the cache. Empty deque → nothing due,
8441 // until a new sample is written (raise_tick_wake covers that).
8442 if let Some(&(_sn, inserted)) = slot.sample_insert_times.front() {
8443 next_due.note((inserted.as_nanos() as u64).saturating_add(slot.lifespan_nanos));
8444 }
8445 }
8446 next_due.into_inner()
8447}
8448
8449/// Checks for all user writers + user readers whether the deadline period
8450/// has been exceeded since the last sample. Every exceedance
8451/// increments the corresponding missed counter by exactly 1
8452/// — regardless of how often `check_deadlines` is called within an
8453/// elapsed window, because we keep setting `last_*`
8454/// to "now" after we have counted.
8455///
8456/// **Init-state semantics:** as long as `last_write`/`last_sample_received`
8457/// is `None` (no real write/sample yet), the deadline
8458/// check does not count. Only after the first real data point does the
8459/// deadline window start. This prevents false misses due to slow
8460/// entity setup (Linux CI/container) before the app even issues a
8461/// write.
8462fn check_deadlines(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
8463 let now_nanos = now.as_nanos() as u64;
8464 let mut next_due = NextDue::new();
8465 for (_eid, arc) in rt.writer_slots_snapshot() {
8466 let Ok(mut slot) = arc.lock() else { continue };
8467 if slot.deadline_nanos == 0 {
8468 continue;
8469 }
8470 let Some(last) = slot.last_write.map(|d| d.as_nanos() as u64) else {
8471 // Never written yet → deadline window not active.
8472 continue;
8473 };
8474 if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
8475 slot.offered_deadline_missed_count =
8476 slot.offered_deadline_missed_count.saturating_add(1);
8477 // Reset the window: the next deadline is counted relative
8478 // to the current tick. `>=` is boundary-stable
8479 // (Spec §2.2.3.7: "deadline has elapsed").
8480 slot.last_write = Some(now);
8481 next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
8482 } else {
8483 next_due.note(last.saturating_add(slot.deadline_nanos));
8484 }
8485 }
8486 for (_eid, arc) in rt.reader_slots_snapshot() {
8487 let Ok(mut slot) = arc.lock() else { continue };
8488 if slot.deadline_nanos == 0 {
8489 continue;
8490 }
8491 let Some(last) = slot.last_sample_received.map(|d| d.as_nanos() as u64) else {
8492 continue;
8493 };
8494 if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
8495 slot.requested_deadline_missed_count =
8496 slot.requested_deadline_missed_count.saturating_add(1);
8497 slot.last_sample_received = Some(now);
8498 next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
8499 } else {
8500 next_due.note(last.saturating_add(slot.deadline_nanos));
8501 }
8502 }
8503 next_due.into_inner()
8504}
8505
8506/// For all local writers + readers: matching against the current
8507/// SEDP cache. A cheap re-run when SEDP events came in — idempotent,
8508/// because ReliableWriter/Reader add_*_proxy are idempotent (same
8509/// GUID → replaced).
8510fn run_matching_pass(rt: &Arc<DcpsRuntime>) {
8511 let writer_ids: Vec<EntityId> = rt.writer_eids();
8512 for eid in writer_ids {
8513 rt.match_local_writer_against_cache(eid);
8514 }
8515 let reader_ids: Vec<EntityId> = rt.reader_eids();
8516 for eid in reader_ids {
8517 rt.match_local_reader_against_cache(eid);
8518 }
8519}
8520
8521/// Returns the default-unicast locator of a discovered remote
8522/// participant.
8523fn remote_user_locators(
8524 prefix: GuidPrefix,
8525 discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
8526) -> Vec<Locator> {
8527 match discovered.lock() {
8528 Ok(cache) => cache
8529 .get(&prefix)
8530 .and_then(|p| p.data.default_unicast_locator)
8531 .into_iter()
8532 .collect(),
8533 Err(_) => Vec::new(),
8534 }
8535}
8536
8537/// Determine the destination for user traffic to a remote endpoint.
8538///
8539/// DDSI-RTPS 2.5 §8.5.3.2/§8.5.3.3: the per-endpoint `unicastLocatorList`
8540/// from the SEDP announce is authoritative. §8.5.5: only when it is empty
8541/// does the sender fall back to the participant `DEFAULT_UNICAST_LOCATOR` from
8542/// SPDP.
8543///
8544/// Before this fix ZeroDDS *always* used the participant default — which
8545/// broke OpenDDS interop: OpenDDS stores only the
8546/// placeholder 127.0.0.1:12345 as the participant default and announces the real user locator
8547/// exclusively per-endpoint.
8548fn endpoint_or_default_locators(
8549 endpoint: &[Locator],
8550 prefix: GuidPrefix,
8551 discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
8552) -> Vec<Locator> {
8553 if !endpoint.is_empty() {
8554 return endpoint.to_vec();
8555 }
8556 remote_user_locators(prefix, discovered)
8557}
8558
8559/// Dispatches a received RTPS datagram to matching user readers.
8560/// Decides, based on the `reader_id` in DATA/DATA_FRAG/HEARTBEAT/GAP,
8561/// which local reader is responsible.
8562/// Strip the 4-byte encapsulation header off the received sample payload.
8563/// Returns `None` if the payload is < 4 bytes or carries an unknown
8564/// scheme (PL_CDR variants would not get here; they go via
8565/// SEDP — if we see such a thing on user endpoints, it is garbage).
8566/// Spec §3.2 zerodds-async-1.0: wakes a registered waker
8567/// after every `sample_tx.send`. `take` consumes the waker, to
8568/// avoid double wakeups — the caller registers a new one after
8569/// every `Pending` result.
8570fn wake_async_waker(slot: &alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>) {
8571 if let Ok(mut g) = slot.lock() {
8572 if let Some(w) = g.take() {
8573 w.wake();
8574 }
8575 }
8576}
8577
8578/// Converts a sample delivered by the ReliableReader into a
8579/// `UserSample` channel entry. For `ChangeKind::Alive` the
8580/// CDR encapsulation header is stripped; for lifecycle markers
8581/// the key hash is reconstructed from the bytes.
8582/// Inspect-endpoint tap dispatch for the DCPS receive path.
8583///
8584/// Called in `handle_user_datagram` when a sample is delivered to
8585/// a user reader. Only when the `inspect` feature is
8586/// on; without the feature no code, no branch.
8587#[cfg(feature = "inspect")]
8588fn dispatch_inspect_dcps_receive_tap(topic: &str, reader_id: EntityId, item: &UserSample) {
8589 let payload: Vec<u8> = match item {
8590 UserSample::Alive { payload, .. } => payload.to_vec(),
8591 UserSample::Lifecycle { key_hash, .. } => key_hash.to_vec(),
8592 };
8593 let ts_ns = std::time::SystemTime::now()
8594 .duration_since(std::time::UNIX_EPOCH)
8595 .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
8596 .unwrap_or(0);
8597 let mut corr: u64 = 0;
8598 for (i, byte) in reader_id.entity_key.iter().enumerate() {
8599 corr |= u64::from(*byte) << (i * 8);
8600 }
8601 corr |= u64::from(reader_id.entity_kind as u8) << 24;
8602 let frame = zerodds_inspect_endpoint::Frame::dcps(topic.to_owned(), ts_ns, corr, payload);
8603 zerodds_inspect_endpoint::tap::dispatch(&frame);
8604}
8605
8606fn delivered_to_user_sample(
8607 sample: &zerodds_rtps::reliable_reader::DeliveredSample,
8608 writer_strengths: &alloc::collections::BTreeMap<[u8; 16], i32>,
8609) -> Option<UserSample> {
8610 use zerodds_rtps::history_cache::ChangeKind;
8611 match sample.kind {
8612 ChangeKind::Alive | ChangeKind::AliveFiltered => {
8613 let writer_guid = sample.writer_guid.to_bytes();
8614 let writer_strength = writer_strengths.get(&writer_guid).copied().unwrap_or(0);
8615 // Encapsulation representation from byte[1] of the header
8616 // (RTPS 2.5 §10.5) — BEFORE stripping. 0x00–0x03 = XCDR1
8617 // (CDR/PL_CDR), 0x06–0x0b = XCDR2 (CDR2/D_CDR2/PL_CDR2).
8618 let representation = encap_representation(&sample.payload);
8619 let big_endian = encap_big_endian(&sample.payload);
8620 strip_user_encap_arc(&sample.payload).map(|payload| UserSample::Alive {
8621 payload,
8622 writer_guid,
8623 writer_strength,
8624 representation,
8625 big_endian,
8626 source_timestamp: sample.source_timestamp,
8627 })
8628 }
8629 ChangeKind::NotAliveDisposed
8630 | ChangeKind::NotAliveUnregistered
8631 | ChangeKind::NotAliveDisposedUnregistered => {
8632 // Lifecycle marker: Spec §9.6.4.8 + §9.6.3.9 requires
8633 // `PID_KEY_HASH` in the inline QoS — the reader reads it
8634 // and propagates it via `DeliveredSample.key_hash`.
8635 // Fallback: with non-spec-conformant writers the
8636 // hash falls back to the first 16 bytes of the key-only payload
8637 // (PLAIN_CDR2-BE key holder).
8638 let kh = sample.key_hash.unwrap_or_else(|| {
8639 let mut h = [0u8; 16];
8640 let n = sample.payload.len().min(16);
8641 h[..n].copy_from_slice(&sample.payload[..n]);
8642 h
8643 });
8644 Some(UserSample::Lifecycle {
8645 key_hash: kh,
8646 kind: sample.kind,
8647 })
8648 }
8649 }
8650}
8651
8652/// Returns the XCDR version from the 4-byte encapsulation header
8653/// (RTPS 2.5 §10.5): `0` = XCDR1 (CDR/PL_CDR, encap byte 0x00–0x05),
8654/// `1` = XCDR2 (CDR2/DELIMITED_CDR2/PL_CDR2, encap byte 0x06–0x0b).
8655/// Default `0` for a too-short payload — XCDR1 is the spec baseline.
8656fn encap_representation(payload: &[u8]) -> u8 {
8657 if payload.len() >= 2 && payload[1] >= 0x06 {
8658 1
8659 } else {
8660 0
8661 }
8662}
8663
8664/// Returns the byte order from the 4-byte encapsulation representation
8665/// identifier (RTPS 2.5 §10.5). The repr-id is a big-endian `uint16`; its low
8666/// bit selects the byte order — the `_BE` variants (CDR_BE 0x0000, PL_CDR_BE
8667/// 0x0002, CDR2_BE 0x0006, D_CDR2_BE 0x0008, PL_CDR2_BE 0x000a) are even, the
8668/// `_LE` variants odd. `true` ⇒ big-endian. A too-short / header-less payload
8669/// (e.g. the intra-runtime bare body) defaults to little-endian (`false`).
8670fn encap_big_endian(payload: &[u8]) -> bool {
8671 payload.len() >= 2 && (payload[1] & 0x01) == 0
8672}
8673
8674/// Checks whether `payload` has a known 4-byte encapsulation header.
8675/// Returns `Some(4)` if so (= offset behind the header), `None` if
8676/// no known scheme. Separated in use from [`strip_user_encap`]:
8677/// here only validation without allocation, for the listener zero-copy
8678/// path (lever E / Sprint D.5d).
8679fn validate_user_encap_offset(payload: &[u8]) -> Option<usize> {
8680 if payload.len() < 4 {
8681 return None;
8682 }
8683 // Accept all data-representation schemes (RTPS 2.5 §10.5,
8684 // table 10.3): byte0 = 0x00, byte1 in:
8685 // 0x00/0x01 CDR_BE/LE (XCDR1 PLAIN_CDR)
8686 // 0x02/0x03 PL_CDR_BE/LE (XCDR1 parameter list, key serial.)
8687 // 0x06/0x07 CDR2_BE/LE (XCDR2 PLAIN_CDR2)
8688 // 0x08/0x09 D_CDR2_BE/LE (XCDR2 DELIMITED_CDR2, @appendable)
8689 // 0x0a/0x0b PL_CDR2_BE/LE (XCDR2 PL_CDR2, @mutable)
8690 // Cyclone often sends XCDR1, OpenDDS/FastDDS XCDR2. We pass
8691 // all through; the typed decoder picks the correct alignment rule
8692 // based on the `representation` (see `encap_representation`).
8693 if payload[0] != 0x00 {
8694 return None;
8695 }
8696 match payload[1] {
8697 0x00..=0x03 | 0x06..=0x0b => Some(4),
8698 _ => None,
8699 }
8700}
8701
8702/// Zero-copy variant: strips the encap header via range slicing
8703/// on the refcounted `Arc<[u8]>` backing store. No heap alloc.
8704/// Spec: `docs/specs/zerodds-zero-copy-1.0.md` §6 wave 2.
8705fn strip_user_encap_arc(
8706 payload: &alloc::sync::Arc<[u8]>,
8707) -> Option<crate::sample_bytes::SampleBytes> {
8708 validate_user_encap_offset(payload).map(|off| {
8709 crate::sample_bytes::SampleBytes::from_arc_slice(
8710 alloc::sync::Arc::clone(payload),
8711 off..payload.len(),
8712 )
8713 })
8714}
8715
8716#[cfg(test)]
8717fn strip_user_encap(payload: &[u8]) -> Option<alloc::vec::Vec<u8>> {
8718 validate_user_encap_offset(payload).map(|off| payload[off..].to_vec())
8719}
8720
8721/// Bench-only phase-timing accumulators. Active with env
8722/// `ZERODDS_PHASE_TIMING=1`. With `ZERODDS_PHASE_DUMP=1` the
8723/// atexit hook prints the totals on drop of the first runtime.
8724#[doc(hidden)]
8725pub static PHASE_HANDLE_USER_NS: core::sync::atomic::AtomicU64 =
8726 core::sync::atomic::AtomicU64::new(0);
8727#[doc(hidden)]
8728pub static PHASE_HANDLE_USER_CALLS: core::sync::atomic::AtomicU64 =
8729 core::sync::atomic::AtomicU64::new(0);
8730#[doc(hidden)]
8731pub static PHASE_WRITE_USER_NS: core::sync::atomic::AtomicU64 =
8732 core::sync::atomic::AtomicU64::new(0);
8733#[doc(hidden)]
8734pub static PHASE_WRITE_USER_CALLS: core::sync::atomic::AtomicU64 =
8735 core::sync::atomic::AtomicU64::new(0);
8736
8737/// Sub-phases in the `handle_user_datagram` receive hot path:
8738/// 0=decode_datagram, 1=slot-lookup+lock, 2=reader.handle_data,
8739/// 3=delivered_to_user_sample, 4=listener+sender-dispatch.
8740/// Active under `ZERODDS_PHASE_TIMING=1`. Each `Instant::now()` bracket
8741/// costs ~50 ns; at a ~3 µs handle that is ~1.6% per sub-phase.
8742#[doc(hidden)]
8743pub static PHASE_HANDLE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8744 core::sync::atomic::AtomicU64::new(0),
8745 core::sync::atomic::AtomicU64::new(0),
8746 core::sync::atomic::AtomicU64::new(0),
8747 core::sync::atomic::AtomicU64::new(0),
8748 core::sync::atomic::AtomicU64::new(0),
8749];
8750
8751/// Sub-phases in `write_user_sample_borrowed` (sender hot path):
8752/// 0=lookup, 1=lock, 2=write_with_heartbeat, 3=send-loop, 4=reserved.
8753/// The detail drilldown into socket.send_to vs. inproc-peer dispatch was
8754/// done once for the connected-UDP lever (showed send_to as
8755/// 97% of the dispatch path); not permanent in the code, because per-phase
8756/// `Instant::now()` itself costs ~50 ns — at a 6 µs send that
8757/// would be 1% overhead and skews the calibrated measurement.
8758#[doc(hidden)]
8759pub static PHASE_WRITE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8760 core::sync::atomic::AtomicU64::new(0),
8761 core::sync::atomic::AtomicU64::new(0),
8762 core::sync::atomic::AtomicU64::new(0),
8763 core::sync::atomic::AtomicU64::new(0),
8764 core::sync::atomic::AtomicU64::new(0),
8765];
8766
8767fn phase_timing_enabled() -> bool {
8768 static CACHE: core::sync::atomic::AtomicI8 = core::sync::atomic::AtomicI8::new(-1);
8769 let v = CACHE.load(core::sync::atomic::Ordering::Relaxed);
8770 if v >= 0 {
8771 return v == 1;
8772 }
8773 let on = std::env::var("ZERODDS_PHASE_TIMING")
8774 .map(|s| s == "1")
8775 .unwrap_or(false);
8776 CACHE.store(
8777 if on { 1 } else { 0 },
8778 core::sync::atomic::Ordering::Relaxed,
8779 );
8780 on
8781}
8782
8783struct PhaseTimer {
8784 start: std::time::Instant,
8785 ns_acc: &'static core::sync::atomic::AtomicU64,
8786 calls_acc: &'static core::sync::atomic::AtomicU64,
8787}
8788
8789impl Drop for PhaseTimer {
8790 fn drop(&mut self) {
8791 let ns = self.start.elapsed().as_nanos() as u64;
8792 self.ns_acc
8793 .fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8794 self.calls_acc
8795 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
8796 }
8797}
8798
8799fn handle_user_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], now: Duration) {
8800 let _phase_guard = if phase_timing_enabled() {
8801 Some(PhaseTimer {
8802 start: std::time::Instant::now(),
8803 ns_acc: &PHASE_HANDLE_USER_NS,
8804 calls_acc: &PHASE_HANDLE_USER_CALLS,
8805 })
8806 } else {
8807 None
8808 };
8809 let pt_on = phase_timing_enabled();
8810 let pt_t0 = if pt_on {
8811 Some(std::time::Instant::now())
8812 } else {
8813 None
8814 };
8815 let parsed = match decode_datagram(bytes) {
8816 Ok(p) => p,
8817 Err(_) => return,
8818 };
8819 // DDSI-RTPS §8.3.4: the effective source of each writer submessage is the
8820 // sourceGuidPrefix from the RTPS header. The reader demux needs it to
8821 // distinguish writer proxies with the same EntityId but a different participant
8822 // (fan-in / multiple publishers on the same topic).
8823 let src_prefix = parsed.header.guid_prefix;
8824 if let (Some(t0), true) = (pt_t0, pt_on) {
8825 let ns = t0.elapsed().as_nanos() as u64;
8826 PHASE_HANDLE_SUB_NS[0].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8827 }
8828 // Per-submessage: take the matching slot mutex individually per
8829 // submessage — no global user_writers/user_readers lock anymore.
8830 // With per-submessage granularity, reader datagrams can be processed in parallel
8831 // to writer AckNacks.
8832 //
8833 // RTPS-F1 (DDSI-RTPS §8.3.4 ReceiverState.haveTimestamp): an INFO_TS
8834 // submessage sets the source timestamp applied to every following DATA in
8835 // the same message, until another INFO_TS (or an I-flag clears it). We
8836 // carry it forward and hand it to the reader so it lands in
8837 // `SampleInfo.source_timestamp`.
8838 let mut cur_source_ts: Option<zerodds_rtps::header_extension::HeTimestamp> = None;
8839 for sub in parsed.submessages {
8840 match sub {
8841 ParsedSubmessage::InfoTimestamp(its) => {
8842 cur_source_ts = if its.invalidate {
8843 None
8844 } else {
8845 Some(its.timestamp)
8846 };
8847 }
8848 ParsedSubmessage::Data(d) => {
8849 // Sprint D.5d lever B — collect-then-dispatch:
8850 // sample conversion + liveliness update inside slot.lock,
8851 // then listener fire + channel send + waker wake
8852 // OUTSIDE the lock.
8853 //
8854 // Cross-vendor fix 2026-05-19: when reader_id ==
8855 // ENTITYID_UNKNOWN (RTPS spec §8.3.7.2: "deliver to all
8856 // matched readers on this topic"), we iterate over
8857 // ALL reader slots and let `handle_data` filter by
8858 // writer_proxies. Cyclone DDS/FastDDS/RTI send
8859 // user DATA with reader_id=UNKNOWN; without this fan-out
8860 // ZeroDDS would drop every such DATA.
8861 let pt_t1 = if pt_on {
8862 Some(std::time::Instant::now())
8863 } else {
8864 None
8865 };
8866 let target_slots: Vec<ReaderSlotArc> = if d.reader_id == EntityId::UNKNOWN {
8867 let snap = rt.reader_slots_snapshot();
8868 let mut v = Vec::with_capacity(snap.len());
8869 v.extend(snap.into_iter().map(|(_, arc)| arc));
8870 v
8871 } else {
8872 let mut v = Vec::with_capacity(1);
8873 if let Some(arc) = rt.reader_slot(d.reader_id) {
8874 v.push(arc);
8875 }
8876 v
8877 };
8878 if let (Some(t1), true) = (pt_t1, pt_on) {
8879 let ns = t1.elapsed().as_nanos() as u64;
8880 PHASE_HANDLE_SUB_NS[1].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8881 }
8882 for arc in target_slots {
8883 // Lever E: alongside the UserSample we carry a
8884 // zero-copy view on the original `Arc<[u8]>` with
8885 // the encap offset — the listener can thereby read into
8886 // the payload without allocation.
8887 let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
8888 let listener;
8889 let waker;
8890 let sender;
8891 #[cfg(feature = "inspect")]
8892 let topic_name;
8893 let pt_t2 = if pt_on {
8894 Some(std::time::Instant::now())
8895 } else {
8896 None
8897 };
8898 {
8899 let Ok(mut slot) = arc.lock() else { continue };
8900 let hd_samples: Vec<_> = slot
8901 .reader
8902 .handle_data(src_prefix, &d, cur_source_ts)
8903 .into_iter()
8904 .collect();
8905 for sample in hd_samples {
8906 // A2 TIME_BASED_FILTER (§2.2.3.12): drop alive samples
8907 // that arrive within minimum_separation of the last
8908 // delivered sample of the same instance. No-op when
8909 // the filter is disabled (tbf_min_separation_nanos==0).
8910 if matches!(
8911 sample.kind,
8912 zerodds_rtps::history_cache::ChangeKind::Alive
8913 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
8914 ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
8915 {
8916 continue;
8917 }
8918 // Listener zero-copy view only for alive samples
8919 // with a valid encap header. Arc::clone is
8920 // an atomic refcount inc, no data copy.
8921 let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
8922 zerodds_rtps::history_cache::ChangeKind::Alive
8923 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
8924 validate_user_encap_offset(&sample.payload)
8925 .map(|off| (Arc::clone(&sample.payload), off))
8926 }
8927 _ => None,
8928 };
8929 if let Some(item) =
8930 delivered_to_user_sample(&sample, &slot.writer_strengths)
8931 {
8932 items.push((item, listener_view));
8933 }
8934 }
8935 if !items.is_empty() {
8936 slot.last_sample_received = Some(now);
8937 slot.samples_delivered_count = slot
8938 .samples_delivered_count
8939 .saturating_add(items.len() as u64);
8940 if !slot.liveliness_alive {
8941 slot.liveliness_alive = true;
8942 slot.liveliness_alive_count =
8943 slot.liveliness_alive_count.saturating_add(1);
8944 }
8945 }
8946 listener = slot.listener.clone();
8947 waker = Arc::clone(&slot.async_waker);
8948 sender = slot.sample_tx.clone();
8949 #[cfg(feature = "inspect")]
8950 {
8951 topic_name = slot.topic_name.clone();
8952 }
8953 }
8954 if let (Some(t2), true) = (pt_t2, pt_on) {
8955 let ns = t2.elapsed().as_nanos() as u64;
8956 PHASE_HANDLE_SUB_NS[2].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8957 }
8958 let pt_t3 = if pt_on {
8959 Some(std::time::Instant::now())
8960 } else {
8961 None
8962 };
8963 // --- Outside slot.lock: dispatch ---
8964 //
8965 // Listener and MPSC are exclusive: if a listener
8966 // (callback) is set, the consumer is on the
8967 // callback path — the additional `sender.send` +
8968 // `wake_async_waker` would be pure overhead AND
8969 // would grow the channel buffer unboundedly
8970 // (memory leak in callback-only apps). We
8971 // dispatch either the callback OR the MPSC, not
8972 // both. A caller (Rust API) that wants take()+listener
8973 // at the same time simply sets NO listener
8974 // and polls via take().
8975 for (item, listener_view) in items {
8976 let (item_repr, item_be) = if let UserSample::Alive {
8977 representation,
8978 big_endian,
8979 ..
8980 } = &item
8981 {
8982 (*representation, u8::from(*big_endian))
8983 } else {
8984 (0, 0)
8985 };
8986 #[cfg(feature = "inspect")]
8987 dispatch_inspect_dcps_receive_tap(&topic_name, d.reader_id, &item);
8988 if let Some(ref l) = listener {
8989 if let Some((arc_payload, off)) = listener_view {
8990 // Zero-copy: slice view into the original Arc.
8991 l(&arc_payload[off..], item_repr, item_be);
8992 }
8993 } else {
8994 let _ = sender.send(item);
8995 wake_async_waker(&waker);
8996 }
8997 }
8998 if let (Some(t3), true) = (pt_t3, pt_on) {
8999 let ns = t3.elapsed().as_nanos() as u64;
9000 PHASE_HANDLE_SUB_NS[4].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
9001 }
9002 } // for arc in target_slots
9003 }
9004 ParsedSubmessage::DataFrag(df) => {
9005 // Lever B+E — see the Data arm above.
9006 // Cross-vendor: same UNKNOWN fan-out as for Data.
9007 let target_slots: Vec<ReaderSlotArc> = if df.reader_id == EntityId::UNKNOWN {
9008 rt.reader_slots_snapshot()
9009 .into_iter()
9010 .map(|(_, arc)| arc)
9011 .collect()
9012 } else {
9013 rt.reader_slot(df.reader_id).into_iter().collect()
9014 };
9015 for arc in target_slots {
9016 let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
9017 let listener;
9018 let waker;
9019 let sender;
9020 #[cfg(feature = "inspect")]
9021 let topic_name;
9022 {
9023 let Ok(mut slot) = arc.lock() else { continue };
9024 for sample in
9025 slot.reader
9026 .handle_data_frag(src_prefix, &df, now, cur_source_ts)
9027 {
9028 // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9029 if matches!(
9030 sample.kind,
9031 zerodds_rtps::history_cache::ChangeKind::Alive
9032 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9033 ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9034 {
9035 continue;
9036 }
9037 let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
9038 zerodds_rtps::history_cache::ChangeKind::Alive
9039 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
9040 validate_user_encap_offset(&sample.payload)
9041 .map(|off| (Arc::clone(&sample.payload), off))
9042 }
9043 _ => None,
9044 };
9045 if let Some(item) =
9046 delivered_to_user_sample(&sample, &slot.writer_strengths)
9047 {
9048 items.push((item, listener_view));
9049 }
9050 }
9051 if !items.is_empty() {
9052 slot.last_sample_received = Some(now);
9053 slot.samples_delivered_count = slot
9054 .samples_delivered_count
9055 .saturating_add(items.len() as u64);
9056 if !slot.liveliness_alive {
9057 slot.liveliness_alive = true;
9058 slot.liveliness_alive_count =
9059 slot.liveliness_alive_count.saturating_add(1);
9060 }
9061 }
9062 listener = slot.listener.clone();
9063 waker = Arc::clone(&slot.async_waker);
9064 sender = slot.sample_tx.clone();
9065 #[cfg(feature = "inspect")]
9066 {
9067 topic_name = slot.topic_name.clone();
9068 }
9069 }
9070 for (item, listener_view) in items {
9071 let (item_repr, item_be) = if let UserSample::Alive {
9072 representation,
9073 big_endian,
9074 ..
9075 } = &item
9076 {
9077 (*representation, u8::from(*big_endian))
9078 } else {
9079 (0, 0)
9080 };
9081 #[cfg(feature = "inspect")]
9082 dispatch_inspect_dcps_receive_tap(&topic_name, df.reader_id, &item);
9083 // See the Data arm: listener and MPSC are exclusive.
9084 if let Some(ref l) = listener {
9085 if let Some((arc_payload, off)) = listener_view {
9086 l(&arc_payload[off..], item_repr, item_be);
9087 }
9088 } else {
9089 let _ = sender.send(item);
9090 wake_async_waker(&waker);
9091 }
9092 }
9093 } // for arc in target_slots (DataFrag)
9094 }
9095 ParsedSubmessage::Heartbeat(h) => {
9096 // Lever B — collect-then-dispatch like the Data arm. An HB can
9097 // unlock samples that were waiting on a hole fill
9098 // (volatile skip, historic eviction).
9099 //
9100 // D.5e Phase-2: synchronous ACKNACK emit on HB receipt
9101 // instead of deferred-via-tick. With `heartbeat_response_delay=0`
9102 // (D.5e default) `tick_outbound(now)` flushes the
9103 // ACKNACK directly for all pending writer_proxies — the tick loop
9104 // no longer has to wait 5 ms.
9105 // Cross-vendor: a HEARTBEAT with reader_id=UNKNOWN is
9106 // "to all matched readers". Cyclone often packs this into
9107 // DATA+HB submessage bundles.
9108 let target_slots: Vec<ReaderSlotArc> = if h.reader_id == EntityId::UNKNOWN {
9109 rt.reader_slots_snapshot()
9110 .into_iter()
9111 .map(|(_, arc)| arc)
9112 .collect()
9113 } else {
9114 rt.reader_slot(h.reader_id).into_iter().collect()
9115 };
9116 for arc in target_slots {
9117 let mut items: Vec<UserSample> = Vec::new();
9118 let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
9119 Vec::new();
9120 let waker;
9121 let sender;
9122 {
9123 let Ok(mut slot) = arc.lock() else { continue };
9124 for sample in slot.reader.handle_heartbeat(src_prefix, &h, now) {
9125 // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9126 if matches!(
9127 sample.kind,
9128 zerodds_rtps::history_cache::ChangeKind::Alive
9129 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9130 ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9131 {
9132 continue;
9133 }
9134 if let Some(item) =
9135 delivered_to_user_sample(&sample, &slot.writer_strengths)
9136 {
9137 items.push(item);
9138 }
9139 }
9140 if !items.is_empty() {
9141 slot.last_sample_received = Some(now);
9142 slot.samples_delivered_count = slot
9143 .samples_delivered_count
9144 .saturating_add(items.len() as u64);
9145 if !slot.liveliness_alive {
9146 slot.liveliness_alive = true;
9147 slot.liveliness_alive_count =
9148 slot.liveliness_alive_count.saturating_add(1);
9149 }
9150 }
9151 // D.5e Phase-2: synchronous ACKNACK directly in the recv thread.
9152 if let Ok(dgs) = slot.reader.tick_outbound(now) {
9153 sync_outbound = dgs;
9154 }
9155 waker = Arc::clone(&slot.async_waker);
9156 sender = slot.sample_tx.clone();
9157 }
9158 for item in items {
9159 let _ = sender.send(item);
9160 wake_async_waker(&waker);
9161 }
9162 // Send ACKNACK datagrams synchronously — no tick-quantization tax.
9163 for dg in sync_outbound {
9164 if let Some(secured) = protect_user_reader_datagram(rt, &dg.bytes) {
9165 for t in dg.targets.iter() {
9166 if is_routable_user_locator(t) {
9167 let _ = rt.user_unicast.send(t, &secured);
9168 }
9169 }
9170 }
9171 }
9172 } // for arc in target_slots (Heartbeat)
9173 }
9174 ParsedSubmessage::Gap(g) => {
9175 // Cross-vendor: Gap with UNKNOWN reader → fan-out.
9176 let target_slots: Vec<ReaderSlotArc> = if g.reader_id == EntityId::UNKNOWN {
9177 rt.reader_slots_snapshot()
9178 .into_iter()
9179 .map(|(_, arc)| arc)
9180 .collect()
9181 } else {
9182 rt.reader_slot(g.reader_id).into_iter().collect()
9183 };
9184 for arc in target_slots {
9185 if let Ok(mut slot) = arc.lock() {
9186 for sample in slot.reader.handle_gap(src_prefix, &g) {
9187 // A2 TIME_BASED_FILTER (§2.2.3.12) — see DATA path.
9188 if matches!(
9189 sample.kind,
9190 zerodds_rtps::history_cache::ChangeKind::Alive
9191 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered
9192 ) && !slot.tbf_should_deliver(sample.key_hash, now.as_nanos())
9193 {
9194 continue;
9195 }
9196 if let Some(item) =
9197 delivered_to_user_sample(&sample, &slot.writer_strengths)
9198 {
9199 let _ = slot.sample_tx.send(item);
9200 wake_async_waker(&slot.async_waker);
9201 }
9202 }
9203 }
9204 }
9205 }
9206 ParsedSubmessage::AckNack(ack) => {
9207 if let Some(arc) = rt.writer_slot(ack.writer_id) {
9208 let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
9209 Vec::new();
9210 if let Ok(mut slot) = arc.lock() {
9211 let base = ack.reader_sn_state.bitmap_base;
9212 let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
9213 let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
9214 slot.writer.handle_acknack(src, base, requested);
9215 // D.5e Phase-2: synchronous resend on NACK receipt.
9216 // An ACKNACK may have listed requested SNs for resend;
9217 // tick delivers the resend datagrams directly in the recv thread.
9218 if let Ok(dgs) = slot.writer.tick(now) {
9219 sync_outbound = dgs;
9220 }
9221 }
9222 // ACK-Event-Cvar: wake `wait_for_acknowledgments`-waiters.
9223 rt.notify_ack_event();
9224 // Send sync resends (no more tick wait). FU2 S3:
9225 // per-target data_protection (a reliable resend of user DATA
9226 // must be encrypted just like the immediate send).
9227 for dg in sync_outbound {
9228 for t in dg.targets.iter() {
9229 if is_routable_user_locator(t) {
9230 if let Some(secured) =
9231 secure_outbound_for_target(rt, ack.writer_id, &dg.bytes, t)
9232 {
9233 let _ = rt.user_unicast.send(t, &secured);
9234 }
9235 }
9236 }
9237 }
9238 }
9239 }
9240 ParsedSubmessage::NackFrag(nf) => {
9241 if let Some(arc) = rt.writer_slot(nf.writer_id) {
9242 if let Ok(mut slot) = arc.lock() {
9243 let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
9244 slot.writer.handle_nackfrag(src, &nf);
9245 }
9246 }
9247 }
9248 _ => {}
9249 }
9250 }
9251}
9252
9253/// Test hook: allows a direct call of `handle_spdp_datagram` from
9254/// other modules without spinning up the whole event loop.
9255/// For internal tests only.
9256#[cfg(test)]
9257pub(crate) fn handle_spdp_datagram_for_test(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
9258 handle_spdp_datagram(rt, bytes);
9259}
9260
9261fn handle_spdp_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
9262 let parsed = match rt.spdp_reader.parse_datagram(bytes) {
9263 Ok(p) => p,
9264 Err(_) => return, // not SPDP or wire error — swallow
9265 };
9266 // Self-discovery filter: ignore our own beacons.
9267 if parsed.sender_prefix == rt.guid_prefix {
9268 return;
9269 }
9270 let is_new = {
9271 if let Ok(mut cache) = rt.discovered.lock() {
9272 cache.insert(parsed.clone())
9273 } else {
9274 false
9275 }
9276 };
9277 // On first discovery: wire the SEDP stack + send out initial
9278 // announcements.
9279 if is_new {
9280 // A1 discovery-server relay: bridge the newly-joined client to every
9281 // already-known client over a single well-known address. Forwards ONLY
9282 // raw SPDP (participant locators) — SEDP (endpoint discovery, incl. ROS-2
9283 // Action endpoints) then proceeds DIRECTLY peer-to-peer, which is exactly
9284 // why Actions keep working (unlike a SEDP-proxying discovery server).
9285 // Plain discovery only; secured relay is a follow-up.
9286 #[cfg(feature = "security")]
9287 let relay_plain = rt.config.security.is_none();
9288 #[cfg(not(feature = "security"))]
9289 let relay_plain = true;
9290 if rt.config.discovery_server && relay_plain {
9291 let new_client = wlp_unicast_targets(core::slice::from_ref(&parsed));
9292 let others: Vec<_> = rt
9293 .discovered_participants()
9294 .into_iter()
9295 .filter(|dp| dp.sender_prefix != parsed.sender_prefix)
9296 .collect();
9297 if let Ok(mut relay) = rt.spdp_relay_cache.lock() {
9298 // 1) tell the new client about every already-known client.
9299 for dp in &others {
9300 if let Some(raw) = relay.get(&dp.sender_prefix) {
9301 for loc in &new_client {
9302 let _ = rt.spdp_unicast.send(loc, raw);
9303 }
9304 }
9305 }
9306 // 2) tell every already-known client about the new client.
9307 for dp in &others {
9308 for loc in wlp_unicast_targets(core::slice::from_ref(dp)) {
9309 let _ = rt.spdp_unicast.send(&loc, bytes);
9310 }
9311 }
9312 // 3) remember the new client's SPDP for future joiners.
9313 relay.insert(parsed.sender_prefix, bytes.to_vec());
9314 }
9315 }
9316 if let Ok(mut sedp) = rt.sedp.lock() {
9317 sedp.on_participant_discovered(&parsed);
9318 }
9319 // Event-driven directed SPDP response (§8.5.3): send OUR own
9320 // SPDP IMMEDIATELY unicast to the newly discovered peer, instead of letting it
9321 // wait for our next periodic multicast beacon (spdp_period=5s, codepit-LXC
9322 // multicast flaky). A spec-conformant peer (OpenDDS)
9323 // processes our auth request ONLY once it has our identity_token from
9324 // our SPDP — without this directed response it waits up to
9325 // spdp_period (seconds latency → cross-vendor ping wait_for_matched
9326 // timeout). NO timeout band-aid: the seconds latency was the missing
9327 // discovery event. Token-less first beacons (security not yet enabled)
9328 // are NOT sent (see security_pending in the announce loop) — the
9329 // periodic/announce_spdp_now path catches up.
9330 #[cfg(feature = "security")]
9331 let beacon_ready =
9332 !(rt.config.security.is_some() && rt.security_builtin_snapshot().is_none());
9333 #[cfg(not(feature = "security"))]
9334 let beacon_ready = true;
9335 if beacon_ready {
9336 let targets = wlp_unicast_targets(core::slice::from_ref(&parsed));
9337 if !targets.is_empty() {
9338 if let Some(secured) = rt
9339 .spdp_beacon
9340 .lock()
9341 .ok()
9342 .and_then(|mut b| b.serialize().ok())
9343 .and_then(|d| secure_outbound_bytes(rt, &d).map(|c| c.to_vec()))
9344 {
9345 for loc in &targets {
9346 let _ = rt.spdp_unicast.send(loc, &secured);
9347 }
9348 }
9349 }
9350 }
9351 }
9352 // FU2: wire the security builtin stack + kick off the auth handshake.
9353 // On EVERY beacon (not only is_new): `handle_remote_endpoints` and
9354 // `begin_handshake_with` are idempotent. This also covers the case
9355 // that the peer was discovered before the auth plugin was active via
9356 // `enable_security_builtins_with_auth` — the next
9357 // beacon refresh then kicks off the handshake. No-op without a plugin,
9358 // without security bits or without an announced identity_token.
9359 if let Some(sec) = rt.security_builtin_snapshot() {
9360 let handshake_dgs = if let Ok(mut s) = sec.lock() {
9361 s.note_remote_vendor(parsed.sender_prefix, parsed.sender_vendor);
9362 s.handle_remote_endpoints(&parsed);
9363 match parsed.data.identity_token.as_ref() {
9364 Some(token) => s
9365 .begin_handshake_with(parsed.sender_prefix, parsed.data.guid.to_bytes(), token)
9366 .unwrap_or_default(),
9367 None => Vec::new(),
9368 }
9369 } else {
9370 Vec::new()
9371 };
9372 for dg in handshake_dgs {
9373 send_discovery_datagram(rt, &dg.targets, &dg.bytes);
9374 }
9375 }
9376 // Mirror the SPDP receive into the builtin DCPSParticipant reader.
9377 // We send on every beacon (also refresh) — Spec §2.2.5.1
9378 // allows it, take() returns the respective current
9379 // data to the user. A reader with KEEP_LAST(1) receives only the newest.
9380 if let Some(sinks) = rt.builtin_sinks_snapshot() {
9381 let dcps_sample =
9382 crate::builtin_topics::ParticipantBuiltinTopicData::from_wire(&parsed.data);
9383 // .7 §2.2.2.2.1.14: drop ignored participants before
9384 // they fall into the builtin reader.
9385 if let Some(filter) = rt.ignore_filter_snapshot() {
9386 let h = crate::instance_handle::InstanceHandle::from_guid(dcps_sample.key);
9387 if filter.is_participant_ignored(h) {
9388 return;
9389 }
9390 }
9391 let _ = sinks.push_participant(&dcps_sample);
9392 }
9393}
9394
9395/// Pushes SEDP events (new pubs/subs) into the 4 builtin-topic
9396/// readers. A new pub/sub produces **two** samples:
9397///
9398/// 1. a `DCPSPublication`/`DCPSSubscription` sample,
9399/// 2. a `DCPSTopic` sample (synthetic from topic name + type name).
9400///
9401/// The native SEDP-topics endpoints (RTPS 2.5 §9.3.2.12 bits 28/29)
9402/// are optional per Spec §8.5.4.4 and covered in ZeroDDS via this
9403/// synthetic derivation — see also
9404/// `endpoint_flag::ALL_STANDARD`, which deliberately omits the
9405/// topics bits. Cyclone/Fast-DDS peers that send their own topic
9406/// announces are ignored (no reader endpoint).
9407fn push_sedp_events_to_builtin_readers(
9408 rt: &Arc<DcpsRuntime>,
9409 events: &zerodds_discovery::sedp::SedpEvents,
9410) {
9411 let Some(sinks) = rt.builtin_sinks_snapshot() else {
9412 return;
9413 };
9414 let filter = rt.ignore_filter_snapshot();
9415 for w in &events.new_publications {
9416 let pub_sample = crate::builtin_topics::PublicationBuiltinTopicData::from_wire(w);
9417 let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_publication(w);
9418 // .7 §2.2.2.2.1.14/.16: consult the participant + publication +
9419 // topic ignore filters.
9420 if let Some(f) = &filter {
9421 let part_h = crate::instance_handle::InstanceHandle::from_guid(w.participant_key);
9422 let pub_h = crate::instance_handle::InstanceHandle::from_guid(w.key);
9423 let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
9424 if f.is_participant_ignored(part_h) || f.is_publication_ignored(pub_h) {
9425 continue;
9426 }
9427 let _ = sinks.push_publication(&pub_sample);
9428 if !f.is_topic_ignored(topic_h) {
9429 let _ = sinks.push_topic(&topic_sample);
9430 }
9431 } else {
9432 let _ = sinks.push_publication(&pub_sample);
9433 let _ = sinks.push_topic(&topic_sample);
9434 }
9435 }
9436 for r in &events.new_subscriptions {
9437 let sub_sample = crate::builtin_topics::SubscriptionBuiltinTopicData::from_wire(r);
9438 let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_subscription(r);
9439 if let Some(f) = &filter {
9440 let part_h = crate::instance_handle::InstanceHandle::from_guid(r.participant_key);
9441 let sub_h = crate::instance_handle::InstanceHandle::from_guid(r.key);
9442 let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
9443 if f.is_participant_ignored(part_h) || f.is_subscription_ignored(sub_h) {
9444 continue;
9445 }
9446 let _ = sinks.push_subscription(&sub_sample);
9447 if !f.is_topic_ignored(topic_h) {
9448 let _ = sinks.push_topic(&topic_sample);
9449 }
9450 } else {
9451 let _ = sinks.push_subscription(&sub_sample);
9452 let _ = sinks.push_topic(&topic_sample);
9453 }
9454 }
9455}
9456
9457/// Binary-property name of the crypto key material in the CryptoToken DataHolder
9458/// (DDS-Security §9.5.2.1.1, cyclone-verified: `dds.cryp.keymat`).
9459#[cfg(feature = "security")]
9460const CRYPTO_TOKEN_PROP: &str = "dds.cryp.keymat";
9461
9462/// CryptoToken `class_id` (§9.5.2.1: `DDS:Crypto:AES_GCM_GMAC` — underscores,
9463/// **not** the plugin-class string with hyphens).
9464#[cfg(feature = "security")]
9465const CRYPTO_TOKEN_CLASS_ID: &str = "DDS:Crypto:AES_GCM_GMAC";
9466
9467/// Builds the `PARTICIPANT_CRYPTO_TOKENS` VolatileSecure message with the
9468/// Kx-encrypted token as a binary property (FU2 S1.4).
9469#[cfg(feature = "security")]
9470fn build_crypto_token_message(
9471 rt: &DcpsRuntime,
9472 remote_prefix: GuidPrefix,
9473 kx_token: Vec<u8>,
9474) -> zerodds_security::generic_message::ParticipantGenericMessage {
9475 use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
9476 use zerodds_security::token::DataHolder;
9477 ParticipantGenericMessage {
9478 message_identity: MessageIdentity {
9479 source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
9480 sequence_number: 1,
9481 },
9482 related_message_identity: MessageIdentity::default(),
9483 destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
9484 destination_endpoint_key: [0; 16],
9485 source_endpoint_key: [0; 16],
9486 message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
9487 message_data: alloc::vec![
9488 DataHolder::new(CRYPTO_TOKEN_CLASS_ID)
9489 .with_binary_property(CRYPTO_TOKEN_PROP, kx_token)
9490 ],
9491 }
9492}
9493
9494/// FU2 S1.4 (send): after handshake completion Kx-encrypt the local data token
9495/// (`gate.local_token`) and send it as
9496/// `PARTICIPANT_CRYPTO_TOKENS` over VolatileSecure.
9497/// Registers the peer's Kx key in the gate beforehand. `None` without a gate
9498/// or on error (drop instead of leak).
9499#[cfg(feature = "security")]
9500fn prepare_crypto_token(
9501 rt: &DcpsRuntime,
9502 remote_prefix: GuidPrefix,
9503 remote_identity: zerodds_security::authentication::IdentityHandle,
9504 secret: zerodds_security::authentication::SharedSecretHandle,
9505) -> Option<zerodds_security::generic_message::ParticipantGenericMessage> {
9506 let gate = rt.config.security.as_ref()?;
9507 let peer_key = remote_prefix.to_bytes();
9508 // ALWAYS register the peer's Kx key — even with rtps=NONE: the per-endpoint
9509 // tokens (discovery_/data_protection) travel Kx-protected over the volatile,
9510 // protect_volatile_datagram needs this key.
9511 gate.register_remote_by_guid_from_secret(peer_key, remote_identity, secret)
9512 .ok()?;
9513 // BUT: send the ParticipantCryptoToken (= SRTPS keymat) ONLY when
9514 // rtps_protection != NONE. With rtps=NONE there is no SRTPS; OpenDDS rejects the
9515 // token (Spdp.cpp:1966 `crypto_handle_==NIL` -> "not configured for RTPS
9516 // Protection", logs `handle_participant_crypto_tokens failed`) and OpenDDS-self
9517 // also does NOT exchange it with rtps=NONE. None here = no participant
9518 // token send; the per-endpoint tokens continue over the separate path.
9519 if gate.rtps_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
9520 return None;
9521 }
9522 // Cross-vendor: the data token travels in PLAINTEXT in the
9523 // ParticipantGenericMessage — it becomes confidential only through the
9524 // SEC_PREFIX/BODY/POSTFIX submessage protection of the whole volatile
9525 // DATA (see protect_volatile_datagram). The `register_*` line above
9526 // created the peer's Kx key in the gate that this protection uses.
9527 let token = gate.local_token().ok()?;
9528 Some(build_crypto_token_message(rt, remote_prefix, token))
9529}
9530
9531/// Per-endpoint crypto handle for a local writer/reader (get-or-register).
9532/// DDS-Security §9.5.3.3: each endpoint has its OWN key material. Registration
9533/// under the write lock (race-free). `None` without an active gate.
9534#[cfg(feature = "security")]
9535fn local_endpoint_crypto_handle(
9536 rt: &DcpsRuntime,
9537 eid: EntityId,
9538 is_writer: bool,
9539) -> Option<zerodds_security::crypto::CryptoHandle> {
9540 let gate = rt.config.security.as_ref()?;
9541 {
9542 let map = rt.endpoint_crypto.read().ok()?;
9543 if let Some(h) = map.get(&eid) {
9544 return Some(*h);
9545 }
9546 }
9547 let mut map = rt.endpoint_crypto.write().ok()?;
9548 if let Some(h) = map.get(&eid) {
9549 return Some(*h);
9550 }
9551 let h = gate.register_local_endpoint(is_writer).ok()?;
9552 map.insert(eid, h);
9553 Some(h)
9554}
9555
9556/// Cross-vendor step 6b (send): per-endpoint `datawriter_crypto_tokens` (for
9557/// every local user writer) + `datareader_crypto_tokens` (for every local
9558/// user reader) to the peer. cyclone needs these to approve the user-endpoint
9559/// match and decode ZeroDDS' user DATA. `source_endpoint_key` = the
9560/// local endpoint GUID; the keymat is the local data key (one key per
9561/// participant in the bench). Empty list without a gate / without user endpoints.
9562#[cfg(feature = "security")]
9563fn prepare_endpoint_crypto_tokens(
9564 rt: &DcpsRuntime,
9565 remote_prefix: GuidPrefix,
9566) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
9567 use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
9568 use zerodds_security::token::DataHolder;
9569 let Some(gate) = rt.config.security.as_ref() else {
9570 return Vec::new();
9571 };
9572 let mut out = Vec::new();
9573 // cyclone associates a datawriter/datareader token via the pair
9574 // (source_endpoint, destination_endpoint). Hence per local endpoint ONE
9575 // token PER matched remote endpoint of **this** peer, with the concrete
9576 // remote GUID as destination_endpoint_key (dst=0 would make cyclone discard it).
9577 //
9578 // §9.5.3.3: the token carries the **per-endpoint** key material of the
9579 // `source_eid` (not the participant key) — the same key with which
9580 // ZeroDDS encodes this endpoint's submessages (protect_user_datagram).
9581 let build = |class: &str,
9582 source_eid: EntityId,
9583 dst: [u8; 16]|
9584 -> Option<ParticipantGenericMessage> {
9585 let is_writer = class == class_id::DATAWRITER_CRYPTO_TOKENS;
9586 let handle = local_endpoint_crypto_handle(rt, source_eid, is_writer)?;
9587 let token = gate.create_endpoint_token(handle).ok()?;
9588 // Dual key (metadata != data, meta-sign-data): cyclone expects
9589 // num_key_mat=2 — submessage keymat (metadata kind) + payload keymat
9590 // (data kind) as TWO DataHolders in this order. Single key
9591 // (all other profiles): only the submessage/endpoint keymat.
9592 let mut dhs = alloc::vec![
9593 DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, token)
9594 ];
9595 if let Some(pay) = gate.endpoint_payload_token(handle) {
9596 dhs.push(
9597 DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, pay),
9598 );
9599 }
9600 Some(ParticipantGenericMessage {
9601 message_identity: MessageIdentity {
9602 source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
9603 sequence_number: 1,
9604 },
9605 related_message_identity: MessageIdentity::default(),
9606 destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
9607 destination_endpoint_key: dst,
9608 source_endpoint_key: Guid::new(rt.guid_prefix, source_eid).to_bytes(),
9609 message_class_id: class.into(),
9610 message_data: dhs,
9611 })
9612 };
9613 // datawriter tokens: per local writer for every matched remote reader
9614 // of this peer (dst = reader GUID).
9615 for (weid, warc) in rt.writer_slots_snapshot() {
9616 if let Ok(slot) = warc.lock() {
9617 for proxy in slot.writer.reader_proxies() {
9618 if proxy.remote_reader_guid.prefix == remote_prefix {
9619 out.extend(build(
9620 class_id::DATAWRITER_CRYPTO_TOKENS,
9621 weid,
9622 proxy.remote_reader_guid.to_bytes(),
9623 ));
9624 }
9625 }
9626 }
9627 }
9628 // datareader tokens: per local reader for every matched remote writer
9629 // of this peer (dst = writer GUID).
9630 for (reid, rarc) in rt.reader_slots_snapshot() {
9631 if let Ok(slot) = rarc.lock() {
9632 for ws in slot.reader.writer_proxies() {
9633 if ws.proxy.remote_writer_guid.prefix == remote_prefix {
9634 out.extend(build(
9635 class_id::DATAREADER_CRYPTO_TOKENS,
9636 reid,
9637 ws.proxy.remote_writer_guid.to_bytes(),
9638 ));
9639 }
9640 }
9641 }
9642 }
9643 // Protected discovery (§8.4.2.4): the secure builtin SEDP endpoints
9644 // (DCPSPublications/SubscriptionsSecure) also need crypto tokens,
9645 // so the peer associates ZeroDDS' data key with them + decodes the secure-SEDP
9646 // submessages. cyclone exchanges these builtin-endpoint tokens
9647 // the same way over the volatile (ff0003c2/c7 + ff0004c2/c7).
9648 if gate
9649 .discovery_protection()
9650 .map(|l| l != ProtectionLevel::None)
9651 .unwrap_or(false)
9652 {
9653 let builtin_pairs = [
9654 (
9655 class_id::DATAWRITER_CRYPTO_TOKENS,
9656 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
9657 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
9658 ),
9659 (
9660 class_id::DATAREADER_CRYPTO_TOKENS,
9661 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
9662 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
9663 ),
9664 (
9665 class_id::DATAWRITER_CRYPTO_TOKENS,
9666 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
9667 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
9668 ),
9669 (
9670 class_id::DATAREADER_CRYPTO_TOKENS,
9671 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
9672 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
9673 ),
9674 ];
9675 for (class, src_eid, dst_eid) in builtin_pairs {
9676 out.extend(build(
9677 class,
9678 src_eid,
9679 Guid::new(remote_prefix, dst_eid).to_bytes(),
9680 ));
9681 }
9682 }
9683 // FastDDS interop: the reliable secure-SPDP builtin (DCPSParticipantsSecure,
9684 // ff0101c2/c7) needs per-endpoint crypto tokens when FastDDS SEC-encrypts the secure-
9685 // SPDP DATA under discovery_protection — otherwise the peer cannot
9686 // decode our secure SPDP -> no secure participant discovery ->
9687 // no token reciprocation. Gated on enable_secure_spdp.
9688 if rt.config.enable_secure_spdp {
9689 let spdp_pairs = [
9690 (
9691 class_id::DATAWRITER_CRYPTO_TOKENS,
9692 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
9693 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
9694 ),
9695 (
9696 class_id::DATAREADER_CRYPTO_TOKENS,
9697 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
9698 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
9699 ),
9700 ];
9701 for (class, src_eid, dst_eid) in spdp_pairs {
9702 out.extend(build(
9703 class,
9704 src_eid,
9705 Guid::new(remote_prefix, dst_eid).to_bytes(),
9706 ));
9707 }
9708 }
9709 // Liveliness protection (§8.4.2.4): the secure-WLP builtin endpoints
9710 // (BuiltinParticipantMessageSecure, ff0200c2/c7) also need per-
9711 // endpoint crypto tokens. cyclone gates the participant security approval
9712 // (and thus the user-endpoint connection) on it — without these tokens
9713 // "connect ... waiting for approval by security" stays hung.
9714 if gate
9715 .liveliness_protection()
9716 .map(|l| l != ProtectionLevel::None)
9717 .unwrap_or(false)
9718 {
9719 let wlp_pairs = [
9720 (
9721 class_id::DATAWRITER_CRYPTO_TOKENS,
9722 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
9723 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
9724 ),
9725 (
9726 class_id::DATAREADER_CRYPTO_TOKENS,
9727 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
9728 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
9729 ),
9730 ];
9731 for (class, src_eid, dst_eid) in wlp_pairs {
9732 out.extend(build(
9733 class,
9734 src_eid,
9735 Guid::new(remote_prefix, dst_eid).to_bytes(),
9736 ));
9737 }
9738 }
9739 out
9740}
9741
9742/// Dedup key of a per-endpoint crypto token: the pair
9743/// (source_endpoint, destination_endpoint). cyclone associates a
9744/// datawriter/datareader token via exactly this pair (§9.5.3.3), so it is
9745/// also the right granularity to remember which tokens have gone out.
9746#[cfg(feature = "security")]
9747fn endpoint_token_key(
9748 m: &zerodds_security::generic_message::ParticipantGenericMessage,
9749) -> [u8; 32] {
9750 let mut k = [0u8; 32];
9751 k[..16].copy_from_slice(&m.source_endpoint_key);
9752 k[16..].copy_from_slice(&m.destination_endpoint_key);
9753 k
9754}
9755
9756/// Filters out the per-endpoint tokens not yet sent. The previously
9757/// used **per-peer** once-guard was too coarse: it snapped shut as soon as the
9758/// participant/secure-SEDP builtin tokens were out — but user endpoints match
9759/// only later (after the secure SEDP). Their tokens then never went out,
9760/// and the peer could never decode ZeroDDS' user DATA. Per-token dedup
9761/// (peer+source+dest) sends each token exactly once — builtins early,
9762/// user endpoints as soon as they match.
9763#[cfg(feature = "security")]
9764fn pending_endpoint_tokens(
9765 msgs: Vec<zerodds_security::generic_message::ParticipantGenericMessage>,
9766 already_sent: &alloc::collections::BTreeSet<[u8; 32]>,
9767) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
9768 msgs.into_iter()
9769 .filter(|m| !already_sent.contains(&endpoint_token_key(m)))
9770 .collect()
9771}
9772
9773/// FU2 S1.4 (recv): Kx-decrypt an incoming `PARTICIPANT_CRYPTO_TOKENS` message
9774/// and install the peer's data key in the gate.
9775/// Afterwards secured user DATA round-trips with this peer.
9776#[cfg(feature = "security")]
9777fn install_crypto_token(
9778 rt: &DcpsRuntime,
9779 remote_prefix: GuidPrefix,
9780 msg: &zerodds_security::generic_message::ParticipantGenericMessage,
9781) {
9782 use zerodds_security::generic_message::class_id;
9783 // Cross-vendor: cyclone sends the data key both as
9784 // participant_crypto_tokens and per-endpoint as datawriter/
9785 // datareader_crypto_tokens. We install the keymat from all three
9786 // under the sender's participant slot (one user endpoint per participant
9787 // in the bench) — so decode_data_datawriter_from decodes the user DATA.
9788 if msg.message_class_id != class_id::PARTICIPANT_CRYPTO_TOKENS
9789 && msg.message_class_id != class_id::DATAWRITER_CRYPTO_TOKENS
9790 && msg.message_class_id != class_id::DATAREADER_CRYPTO_TOKENS
9791 {
9792 return;
9793 }
9794 let Some(gate) = rt.config.security.as_ref() else {
9795 return;
9796 };
9797 let peer_key = remote_prefix.to_bytes();
9798 // `message_data` is a sequence<DataHolder> (DDS-Security §7.4.4.3
9799 // ParticipantGenericMessage): cyclone packs MULTIPLE CryptoTokens (its own
9800 // key material per endpoint, different transformation_key_id) into ONE
9801 // message. Install ALL — taking only `.first()` lost the
9802 // endpoint keys (key_id 2..N) and the secure SEDP stayed undecodable.
9803 // Plaintext token (confidentiality was provided by the submessage protection of
9804 // the transporting volatile DATA, see unprotect_volatile_datagram).
9805 // DDS-Security §9.5.2 vs §9.5.3: the PARTICIPANT crypto token carries the
9806 // message-level key (SRTPS, decode_secured_rtps_message -> slots[peer]); the
9807 // datawriter/datareader tokens carry per-endpoint data keys that belong ONLY in
9808 // the key_id path (remote_by_key_id, decode_data_by_key_id). Putting both
9809 // into slots[peer] let the last-installed (datareader) overwrite the
9810 // participant key -> message-level SRTPS tag mismatch.
9811 let is_participant = msg.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS;
9812 for dh in &msg.message_data {
9813 if let Some(token) = dh.binary_property(CRYPTO_TOKEN_PROP) {
9814 let _ = if is_participant {
9815 gate.set_remote_data_token_by_guid(&peer_key, token)
9816 } else {
9817 gate.install_remote_endpoint_token(token)
9818 };
9819 }
9820 }
9821}
9822
9823// RTPS submessage IDs for the VolatileSecure submessage-protection surgery.
9824#[cfg(feature = "security")]
9825const SMID_DATA: u8 = 0x15;
9826#[cfg(feature = "security")]
9827const SMID_SEC_PREFIX: u8 = 0x31;
9828#[cfg(feature = "security")]
9829const SMID_SEC_POSTFIX: u8 = 0x32;
9830// Further writer submessage IDs (DDSI-RTPS 2.5 §8.3.7). Per DDS-Security
9831// §8.4.2.4 (is_submessage_protected=TRUE, DataWriter) ALL submessages sent by the
9832// writer — not only DATA — MUST be protected via encode_datawriter_submessage.
9833// HEARTBEAT is the critical one: without it the remote
9834// reader cannot NACK a missing sequence number (= no reliable recovery).
9835#[cfg(feature = "security")]
9836const SMID_HEARTBEAT: u8 = 0x07;
9837#[cfg(feature = "security")]
9838const SMID_GAP: u8 = 0x08;
9839#[cfg(feature = "security")]
9840const SMID_DATA_FRAG: u8 = 0x16;
9841#[cfg(feature = "security")]
9842const SMID_HEARTBEAT_FRAG: u8 = 0x13;
9843// Reader submessages (DDSI-RTPS 2.5 §8.3.7): under `metadata_protection_kind
9844// != NONE` to be protected via `encode_datareader_submessage` (§8.4.2.4) with the per-endpoint
9845// reader key — otherwise a spec-conformant remote writer
9846// (cyclone under discovery=ENCRYPT) discards the clear ACKNACK and never re-sends.
9847#[cfg(feature = "security")]
9848const SMID_ACKNACK: u8 = 0x06;
9849#[cfg(feature = "security")]
9850const SMID_NACK_FRAG: u8 = 0x12;
9851
9852/// `true` if the submessage ID is a submessage sent by the DataReader
9853/// (ACKNACK/NACK_FRAG) — datareader protection path.
9854#[cfg(feature = "security")]
9855fn is_protected_reader_submessage(id: u8) -> bool {
9856 matches!(id, SMID_ACKNACK | SMID_NACK_FRAG)
9857}
9858
9859/// Extracts the `reader_id` (sender) from an ACKNACK/NACK_FRAG submessage:
9860/// offset 4 (after header(4)), directly before the writer_id (offset 8).
9861#[cfg(feature = "security")]
9862fn reader_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
9863 if !is_protected_reader_submessage(id) {
9864 return None;
9865 }
9866 let raw: [u8; 4] = submsg.get(4..8)?.try_into().ok()?;
9867 Some(EntityId::from_bytes(raw))
9868}
9869
9870/// `true` if the submessage ID is a submessage sent by the DataWriter that,
9871/// under `metadata_protection_kind != NONE`, must be protected via `encode_datawriter_submessage`
9872/// (DDS-Security §8.4.2.4). ACKNACK/NACK_FRAG are
9873/// reader submessages (datareader path) and are excluded here.
9874#[cfg(feature = "security")]
9875fn is_protected_writer_submessage(id: u8) -> bool {
9876 matches!(
9877 id,
9878 SMID_DATA | SMID_DATA_FRAG | SMID_HEARTBEAT | SMID_HEARTBEAT_FRAG | SMID_GAP
9879 )
9880}
9881
9882/// Walks the submessages of an RTPS datagram from `offset` and returns
9883/// `(submessage_id, start, total_len)`. `octetsToNextHeader == 0` means
9884/// "to the end of the datagram" (RTPS §8.3.3.2.3).
9885#[cfg(feature = "security")]
9886fn walk_submessages(bytes: &[u8]) -> Vec<(u8, usize, usize)> {
9887 let mut out = Vec::new();
9888 let mut o = 20; // RTPS header
9889 while o + 4 <= bytes.len() {
9890 let id = bytes[o];
9891 let le = bytes[o + 1] & 0x01 != 0;
9892 let raw = if le {
9893 u16::from_le_bytes([bytes[o + 2], bytes[o + 3]])
9894 } else {
9895 u16::from_be_bytes([bytes[o + 2], bytes[o + 3]])
9896 } as usize;
9897 let body = if raw == 0 { bytes.len() - (o + 4) } else { raw };
9898 let total = 4 + body;
9899 if o + total > bytes.len() {
9900 break;
9901 }
9902 out.push((id, o, total));
9903 o += total;
9904 }
9905 out
9906}
9907
9908/// Cross-vendor VolatileSecure (send): replaces every DATA submessage in the
9909/// datagram with the cyclone-conformant `SEC_PREFIX`/`SEC_BODY`/`SEC_POSTFIX`
9910/// sequence (encrypted with the peer's Kx key). Other submessages
9911/// (INFO_DST/INFO_TS/HEARTBEAT) stay unchanged. Returns the datagram
9912/// unchanged if no DATA submessage is present (e.g. a pure
9913/// HEARTBEAT tick). `None` only on a crypto error (drop instead of leak).
9914#[cfg(feature = "security")]
9915fn protect_volatile_datagram(
9916 rt: &DcpsRuntime,
9917 bytes: &[u8],
9918 peer_key: &[u8; 12],
9919) -> Option<Vec<u8>> {
9920 let gate = rt.config.security.as_ref()?;
9921 if bytes.len() < 20 {
9922 return Some(bytes.to_vec());
9923 }
9924 let subs = walk_submessages(bytes);
9925 // DDS-Security §8.4.2.4: ParticipantVolatileMessageSecure is submessage-
9926 // protected — ALL submessages sent by the endpoint MUST be protected with the Kx key,
9927 // not only DATA. This holds for BOTH directions:
9928 // * writer submessages (DATA, DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP)
9929 // * reader submessages (ACKNACK, NACK_FRAG)
9930 // cyclone/FastDDS otherwise discard the WHOLE volatile sample with "clear
9931 // submsg from protected src" → the crypto-token exchange over the volatile
9932 // stalls. write_with_heartbeat bundles DATA+HEARTBEAT into ONE datagram; if
9933 // the HEARTBEAT stayed clear, the whole token sample was lost (cross-vendor
9934 // cyclone→ZeroDDS responder).
9935 // The reader ACKNACK: OpenDDS' RtpsUdpReceiveStrategy::check_encoded requires
9936 // protection for the volatile reader (ff0202c4, is_submessage_protected=TRUE) and
9937 // otherwise drops the clear ACKNACK ("Submessage requires protection") → its
9938 // volatile WRITER never gets an ACK → considers the token delivery
9939 // unacknowledged → zerodds NEVER sends the SRTPS-protected secure SEDP → no
9940 // user-endpoint match. The volatile channel uses ONE shared Kx session key
9941 // (KDF from the shared secret, §9.5.3.3.4.4), symmetric for both directions
9942 // → protect the ACKNACK with the same Kx key as the DATA.
9943 if !subs.iter().any(|(id, _, _)| {
9944 is_protected_writer_submessage(*id) || is_protected_reader_submessage(*id)
9945 }) {
9946 return Some(bytes.to_vec()); // no protection-worthy submessage -> unchanged
9947 }
9948 let mut out = Vec::with_capacity(bytes.len() + 64);
9949 out.extend_from_slice(&bytes[..20]);
9950 for (id, start, total) in subs {
9951 let submsg = &bytes[start..start + total];
9952 if is_protected_writer_submessage(id) || is_protected_reader_submessage(id) {
9953 match gate.encode_kx_datawriter_for(peer_key, submsg) {
9954 Ok(sec) => out.extend_from_slice(&sec),
9955 Err(_) => return None, // drop instead of plaintext leak
9956 }
9957 } else {
9958 out.extend_from_slice(submsg);
9959 }
9960 }
9961 Some(out)
9962}
9963
9964/// Cross-vendor VolatileSecure (recv): recognizes a `SEC_PREFIX`/`SEC_BODY`/
9965/// `SEC_POSTFIX` sequence, decodes it with the peer's Kx key to the
9966/// original DATA submessage and builds a plain RTPS datagram for the
9967/// `volatile_reader`. `None` if no SEC_* sequence is present (then the normal
9968/// path) or on a crypto error.
9969#[cfg(feature = "security")]
9970fn unprotect_volatile_datagram(
9971 rt: &DcpsRuntime,
9972 bytes: &[u8],
9973 peer_key: &[u8; 12],
9974) -> Option<Vec<u8>> {
9975 let gate = rt.config.security.as_ref()?;
9976 if bytes.len() < 20 {
9977 return None;
9978 }
9979 let subs = walk_submessages(bytes);
9980 // Cyclone/FastDDS bundle, via xpack, MULTIPLE SEC_*-protected volatile
9981 // submessages (all with the Kx key) into ONE datagram. So there can be
9982 // multiple SEC_PREFIX/BODY/POSTFIX triples — transform ALL back
9983 // (like unprotect_user_datagram). Decoding only the first block (an earlier
9984 // bug) left every bundled token sample after the first encrypted;
9985 // the VOLATILE writer does not retransmit them → deterministic
9986 // token loss (no "flaky" transport, all same-host). `None` if there is no
9987 // SEC_PREFIX at all (plaintext) or the Kx decode fails (= not a volatile datagram,
9988 // e.g. secure SEDP with a per-endpoint key).
9989 if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
9990 return None;
9991 }
9992 let mut out = Vec::with_capacity(bytes.len());
9993 out.extend_from_slice(&bytes[..20]);
9994 let mut i = 0;
9995 while i < subs.len() {
9996 let (id, start, total) = subs[i];
9997 if id == SMID_SEC_PREFIX {
9998 let postfix_idx = subs[i..]
9999 .iter()
10000 .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
10001 .map(|off| i + off)?;
10002 let (_, q_start, q_total) = subs[postfix_idx];
10003 let sec_wire = &bytes[start..q_start + q_total];
10004 let submsg = gate.decode_kx_datawriter_from(peer_key, sec_wire).ok()?;
10005 out.extend_from_slice(&submsg);
10006 i = postfix_idx + 1;
10007 } else {
10008 out.extend_from_slice(&bytes[start..start + total]);
10009 i += 1;
10010 }
10011 }
10012 Some(out)
10013}
10014
10015/// Protects a peer's volatile outbound datagrams (DATA -> SEC_*).
10016/// HEARTBEAT/ACKNACK datagrams (without DATA) stay unchanged; datagrams
10017/// with a crypto error are dropped.
10018#[cfg(feature = "security")]
10019fn protect_volatile_outbound(
10020 rt: &DcpsRuntime,
10021 remote_prefix: GuidPrefix,
10022 dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>,
10023) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
10024 let peer_key = remote_prefix.to_bytes();
10025 dgs.into_iter()
10026 .filter_map(|dg| {
10027 protect_volatile_datagram(rt, &dg.bytes, &peer_key).map(|bytes| {
10028 zerodds_rtps::message_builder::OutboundDatagram {
10029 bytes,
10030 targets: dg.targets,
10031 }
10032 })
10033 })
10034 .collect()
10035}
10036
10037/// Cross-vendor (send): replaces EVERY submessage sent by the DataWriter (DATA,
10038/// DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP) with the cyclone-conformant
10039/// SEC_PREFIX/BODY/POSTFIX sequence, encrypted with the **local data key**.
10040/// DDS-Security §8.4.2.4 (`is_submessage_protected=TRUE`, DataWriter): ALL
10041/// writer submessages MUST be protected via `encode_datawriter_submessage`
10042/// — in particular the HEARTBEAT, otherwise the remote reader cannot NACK missing
10043/// sequence numbers (no reliable recovery). Framing submessages
10044/// (INFO_TS/INFO_DST/...) stay unchanged; `None` on a crypto error.
10045#[cfg(feature = "security")]
10046fn protect_user_datagram(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10047 let gate = rt.config.security.as_ref()?;
10048 if bytes.len() < 20 {
10049 return Some(bytes.to_vec());
10050 }
10051 let subs = walk_submessages(bytes);
10052 if !subs
10053 .iter()
10054 .any(|(id, _, _)| is_protected_writer_submessage(*id))
10055 {
10056 return Some(bytes.to_vec());
10057 }
10058 // §9.5.3.3 per-endpoint key: all writer submessages of a datagram
10059 // come from the same writer. Take the writer_id from the first protected
10060 // submessage + look up the per-endpoint handle. No handle
10061 // (unregistered endpoint) → participant-key fallback.
10062 let endpoint_handle = subs
10063 .iter()
10064 .find(|(id, _, _)| is_protected_writer_submessage(*id))
10065 .and_then(|&(id, start, total)| writer_eid_in_submessage(&bytes[start..start + total], id))
10066 .and_then(|weid| local_endpoint_crypto_handle(rt, weid, true));
10067 let mut out = Vec::with_capacity(bytes.len() + 64);
10068 out.extend_from_slice(&bytes[..20]);
10069 for (id, start, total) in subs {
10070 let submsg = &bytes[start..start + total];
10071 if is_protected_writer_submessage(id) {
10072 let sec = match endpoint_handle {
10073 Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
10074 None => gate.encode_data_datawriter_local(submsg),
10075 };
10076 match sec {
10077 Ok(s) => out.extend_from_slice(&s),
10078 Err(_) => return None,
10079 }
10080 } else {
10081 out.extend_from_slice(submsg);
10082 }
10083 }
10084 Some(out)
10085}
10086
10087/// Extracts the `writer_id` from an RTPS writer submessage. DATA/DATA_FRAG:
10088/// offset 12 (header(4)+extraFlags(2)+octetsToInlineQos(2)+readerId(4));
10089/// HEARTBEAT/GAP/HEARTBEAT_FRAG: offset 8 (header(4)+readerId(4)).
10090#[cfg(feature = "security")]
10091fn writer_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
10092 let off = match id {
10093 SMID_DATA | SMID_DATA_FRAG => 12,
10094 SMID_HEARTBEAT | SMID_GAP | SMID_HEARTBEAT_FRAG => 8,
10095 _ => return None,
10096 };
10097 let raw: [u8; 4] = submsg.get(off..off + 4)?.try_into().ok()?;
10098 Some(EntityId::from_bytes(raw))
10099}
10100
10101/// Cross-vendor user DATA (recv): decodes the SEC_* sequence with the sender's
10102/// data key (`peer_key` = sender GuidPrefix) back to the DATA submessage.
10103/// `None` if no SEC_* sequence is present (normal path) or on a crypto error.
10104#[cfg(feature = "security")]
10105fn unprotect_user_datagram(rt: &DcpsRuntime, bytes: &[u8], peer_key: &[u8; 12]) -> Option<Vec<u8>> {
10106 let gate = rt.config.security.as_ref()?;
10107 if bytes.len() < 20 {
10108 return None;
10109 }
10110 let subs = walk_submessages(bytes);
10111 // §8.4.2.4: the peer SEC_*-wrapped EVERY writer submessage individually
10112 // (DATA, HEARTBEAT, GAP, ...). So there can be MULTIPLE SEC_PREFIX/BODY/
10113 // POSTFIX triples in the same datagram — transform them all back. `None`
10114 // only if there is no SEC_* sequence at all (normal/plaintext path).
10115 if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
10116 return None;
10117 }
10118 let mut out = Vec::with_capacity(bytes.len());
10119 out.extend_from_slice(&bytes[..20]);
10120 let mut i = 0;
10121 while i < subs.len() {
10122 let (id, start, total) = subs[i];
10123 if id == SMID_SEC_PREFIX {
10124 // Find the matching SEC_POSTFIX from i; the block is [prefix..postfix].
10125 let postfix_idx = subs[i..]
10126 .iter()
10127 .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
10128 .map(|off| i + off)?;
10129 let (_, q_start, q_total) = subs[postfix_idx];
10130 let sec_wire = &bytes[start..q_start + q_total];
10131 // key_id-based decode: the peer has, per endpoint (user +
10132 // secure-builtin discovery), its own key material; the correct
10133 // key is found via the transformation_key_id in the CryptoHeader.
10134 // Fallback for transformation_key_id=0: this is NOT a per-
10135 // endpoint token key, but the participant-level key derived from the
10136 // SharedSecret (DDS-Security Tab.73, AES256-GCM, sender_key_id
10137 // =0) — cyclone protects with it under rtps_protection. That one is decoded by the
10138 // Kx path (peer-prefix-indexed SharedSecret key).
10139 let mut submsg = gate
10140 .decode_data_by_key_id(sec_wire)
10141 .or_else(|_| gate.decode_data_datawriter_from(peer_key, sec_wire))
10142 .or_else(|_| gate.decode_kx_datawriter_from(peer_key, sec_wire))
10143 .ok()?;
10144 // Correct octetsToNextHeader to the real body length: cyclone
10145 // wraps every writer submessage INDIVIDUALLY; within its SEC_BODY
10146 // it is the last one -> octetsToNextHeader=0 ("to the end of the message").
10147 // When concatenating multiple decoded blocks (e.g. DATA + piggybacked
10148 // HEARTBEAT), otn=0 would make the strict decode_datagram swallow the following
10149 // submessage as payload -> the reader would never see the
10150 // HEARTBEAT and would block as a late joiner on the SN gap.
10151 if submsg.len() >= 4 {
10152 let le = submsg[1] & zerodds_rtps::FLAG_E_LITTLE_ENDIAN != 0;
10153 let otn = u16::try_from(submsg.len() - 4).unwrap_or(0);
10154 let b = if le {
10155 otn.to_le_bytes()
10156 } else {
10157 otn.to_be_bytes()
10158 };
10159 submsg[2] = b[0];
10160 submsg[3] = b[1];
10161 }
10162 out.extend_from_slice(&submsg);
10163 i = postfix_idx + 1;
10164 } else {
10165 out.extend_from_slice(&bytes[start..start + total]);
10166 i += 1;
10167 }
10168 }
10169 Some(out)
10170}
10171
10172/// §8.5.1.9.1 / §9.5.3.3.1 data_protection (send): encrypts ONLY the
10173/// SerializedPayload INSIDE each DATA submessage (payload layer). The
10174/// submessage header, octetsToInlineQos, InlineQoS and the flags (E/Q/D/K)
10175/// stay byte-identical; only the N-flag (NonStandardPayload, §8.3.8.2) is
10176/// set and octetsToNextHeader adjusted to the new payload length. This is
10177/// the spec-conformant + cyclone-interop form of data_protection (counterpart:
10178/// metadata_protection = whole submessage SEC_*-wrapped). Applied as the INNER
10179/// layer BEFORE the submessage/message protection. `None` on a
10180/// crypto error (drop instead of leak); a datagram without DATA stays unchanged.
10181#[cfg(feature = "security")]
10182fn protect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10183 use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
10184 use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
10185 let gate = rt.config.security.as_ref()?;
10186 if bytes.len() < 20 {
10187 return Some(bytes.to_vec());
10188 }
10189 let subs = walk_submessages(bytes);
10190 if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
10191 return Some(bytes.to_vec());
10192 }
10193 let mut out = Vec::with_capacity(bytes.len() + 64);
10194 out.extend_from_slice(&bytes[..20]);
10195 for (id, start, total) in subs {
10196 let submsg = &bytes[start..start + total];
10197 if id != SMID_DATA {
10198 out.extend_from_slice(submsg);
10199 continue;
10200 }
10201 let flags = submsg[1];
10202 let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
10203 // data_protection payload key: the **per-endpoint DataWriter key**
10204 // (§9.5.3.3.1). cyclone associates the DataWriter strictly with its
10205 // datawriter_crypto_handle and decodes the SerializedPayload ONLY with
10206 // this key — the participant key yields "Invalid Crypto
10207 // Handle" in cyclone. The key is sent to the peer as a datawriter_crypto_token;
10208 // the reader finds it via the transformation_key_id in the CryptoHeader.
10209 let handle = writer_eid_in_submessage(submsg, id)
10210 .and_then(|w| local_endpoint_crypto_handle(rt, w, true))?;
10211 // Payload boundary: read_body_with_flags returns serialized_payload as
10212 // an Arc of body[pos..] -> payload = the last plen bytes of the submessage.
10213 let body = &submsg[4..];
10214 let ds = DataSubmessage::read_body_with_flags(body, le, flags).ok()?;
10215 let plen = ds.serialized_payload.len();
10216 let payload_off = submsg.len() - plen;
10217 let enc = gate
10218 .encode_serialized_payload(handle, &ds.serialized_payload)
10219 .ok()?;
10220 let new_body_len = (payload_off - 4) + enc.len();
10221 if new_body_len > u16::MAX as usize {
10222 return None;
10223 }
10224 out.push(submsg[0]);
10225 out.push(flags | DATA_FLAG_NON_STANDARD);
10226 let otn = new_body_len as u16;
10227 if le {
10228 out.extend_from_slice(&otn.to_le_bytes());
10229 } else {
10230 out.extend_from_slice(&otn.to_be_bytes());
10231 }
10232 // Body prefix (extraFlags..InlineQoS) verbatim, then encrypted payload.
10233 out.extend_from_slice(&submsg[4..payload_off]);
10234 out.extend_from_slice(&enc);
10235 }
10236 Some(out)
10237}
10238
10239/// Result of the inner payload layer on receipt (§8.5.1.9.4).
10240#[cfg(feature = "security")]
10241enum PayloadDecode {
10242 /// No DATA submessage carries the N-flag — plaintext path, pass the datagram
10243 /// on unchanged.
10244 NotEncrypted,
10245 /// Successfully decrypted — use the plaintext datagram.
10246 Decoded(Vec<u8>),
10247 /// N-flag set, but decryption failed. The datagram MUST
10248 /// be discarded — passing an undecodable encrypted payload as
10249 /// ciphertext gives the reader garbage (§8.5: reject). The
10250 /// reliable re-send catches up on the sample once the key is installed
10251 /// resp. another (e.g. inproc/message-level) copy delivers it.
10252 Failed,
10253}
10254
10255/// `true` if the SerializedPayload begins with a CryptoHeader (§9.5.3.3.1):
10256/// the first 4 bytes are a CryptoTransformKind != NONE
10257/// (AES128_GMAC/GCM, AES256_GMAC/GCM = `[0,0,0,1..=4]`). A plaintext CDR
10258/// encapsulation carries either a different first byte pair (CDR_LE `[0,1]`,
10259/// XCDR2 `[0,6/7]`, PL_CDR `[0,2/3]`) or — for CDR_BE `[0,0]` — options
10260/// `[0,0]`, so it does not collide with the transform kinds 1..=4. Serves as
10261/// detection for vendors (cyclone) that encrypt the data_protection payload
10262/// without setting the N-flag of the DATA submessage.
10263#[cfg(feature = "security")]
10264fn payload_has_crypto_header(payload: &[u8]) -> bool {
10265 matches!(payload, [0, 0, 0, 1..=4, ..])
10266}
10267
10268/// §8.5.1.9.4 / §9.5.3.3.1 data_protection (recv): decrypts the
10269/// SerializedPayload of each DATA submessage whose payload begins with a CryptoHeader
10270/// — recognized by the set N-flag (zero↔zero, [`protect_user_payload`])
10271/// OR by the CryptoTransformKind signature (cyclone does not set the N-flag).
10272/// The tag verification of the GCM open IS the detection: if the decode fails
10273/// and the N-flag was not set, the submessage is passed through as plaintext
10274/// (false positive of the signature heuristic). The key is found via the
10275/// `transformation_key_id` (key_id), the sender prefix (peer slot) or — for
10276/// key_id=0 (participant/Kx key, cyclone) — the Kx key material.
10277/// `NotEncrypted` if no DATA submessage was decrypted; `Failed` only
10278/// on an N-flag decode error (§8.5: reject undecryptable).
10279#[cfg(feature = "security")]
10280fn unprotect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> PayloadDecode {
10281 use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
10282 use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
10283 let Some(gate) = rt.config.security.as_ref() else {
10284 return PayloadDecode::NotEncrypted;
10285 };
10286 if bytes.len() < 20 {
10287 return PayloadDecode::NotEncrypted;
10288 }
10289 // Sender prefix (RTPS header bytes[8..20]) as a fallback key index, if the
10290 // transformation_key_id in the CryptoHeader is not uniquely in the remote index
10291 // (zero↔zero indexed via the peer slot, cyclone strictly via key_id).
10292 let mut peer_key = [0u8; 12];
10293 peer_key.copy_from_slice(&bytes[8..20]);
10294 let subs = walk_submessages(bytes);
10295 let mut out = Vec::with_capacity(bytes.len());
10296 out.extend_from_slice(&bytes[..20]);
10297 let mut did_decode = false;
10298 for (id, start, total) in subs {
10299 let submsg = &bytes[start..start + total];
10300 if id != SMID_DATA {
10301 out.extend_from_slice(submsg);
10302 continue;
10303 }
10304 let flags = submsg[1];
10305 let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
10306 let nflag = flags & DATA_FLAG_NON_STANDARD != 0;
10307 let body = &submsg[4..];
10308 let Ok(ds) = DataSubmessage::read_body_with_flags(body, le, flags) else {
10309 // Parse error of a DATA marked as encrypted -> drop;
10310 // a pure plaintext DATA never made read_body_with_flags fail,
10311 // so a set N-flag is the only reason here.
10312 if nflag {
10313 return PayloadDecode::Failed;
10314 }
10315 out.extend_from_slice(submsg);
10316 continue;
10317 };
10318 // Only attempt when the payload is recognizable as encrypted:
10319 // N-flag (zero↔zero) or CryptoHeader signature (cyclone without an N-flag).
10320 if !nflag && !payload_has_crypto_header(&ds.serialized_payload) {
10321 out.extend_from_slice(submsg);
10322 continue;
10323 }
10324 let plen = ds.serialized_payload.len();
10325 let payload_off = submsg.len() - plen;
10326 let pdec = gate
10327 .decode_serialized_payload(&ds.serialized_payload)
10328 .or_else(|_| gate.decode_serialized_payload_from(&peer_key, &ds.serialized_payload))
10329 .or_else(|_| gate.decode_serialized_payload_kx(&peer_key, &ds.serialized_payload));
10330 let Ok(dec) = pdec else {
10331 // §8.5: if the N-flag was set, the payload is surely encrypted
10332 // and the reader would get garbage -> drop (reliable re-send catches it
10333 // up after key install). If only the signature heuristic was the trigger
10334 // (no N-flag), it is a plaintext CDR_BE payload whose options
10335 // happen to look like a TransformKind -> pass through unchanged.
10336 if nflag {
10337 return PayloadDecode::Failed;
10338 }
10339 out.extend_from_slice(submsg);
10340 continue;
10341 };
10342 let new_body_len = (payload_off - 4) + dec.len();
10343 if new_body_len > u16::MAX as usize {
10344 return PayloadDecode::Failed;
10345 }
10346 out.push(submsg[0]);
10347 out.push(flags & !DATA_FLAG_NON_STANDARD);
10348 let otn = new_body_len as u16;
10349 if le {
10350 out.extend_from_slice(&otn.to_le_bytes());
10351 } else {
10352 out.extend_from_slice(&otn.to_be_bytes());
10353 }
10354 out.extend_from_slice(&submsg[4..payload_off]);
10355 out.extend_from_slice(&dec);
10356 did_decode = true;
10357 }
10358 if did_decode {
10359 PayloadDecode::Decoded(out)
10360 } else {
10361 PayloadDecode::NotEncrypted
10362 }
10363}
10364
10365/// `true` if the EntityId is one of the four secure-SEDP discovery endpoints
10366/// (DCPSPublicationsSecure/DCPSSubscriptionsSecure, EntityIds ff0003c2/c7 +
10367/// ff0004c2/c7). Controls whether a SEDP datagram is protected-discovery traffic
10368/// and must be SEC_*-protected (DDS-Security §8.4.2.4).
10369#[cfg(feature = "security")]
10370fn is_secure_sedp_entity(e: EntityId) -> bool {
10371 e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER
10372 || e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER
10373 || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER
10374 || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER
10375}
10376
10377/// `true` if the datagram carries a submessage to/from a secure-SEDP endpoint
10378/// — then it is protected-discovery traffic.
10379#[cfg(feature = "security")]
10380fn is_secure_sedp_datagram(bytes: &[u8]) -> bool {
10381 let Ok(parsed) = decode_datagram(bytes) else {
10382 return false;
10383 };
10384 parsed.submessages.iter().any(|s| {
10385 let ids = match s {
10386 ParsedSubmessage::Data(d) => [Some(d.writer_id), Some(d.reader_id)],
10387 ParsedSubmessage::DataFrag(d) => [Some(d.writer_id), Some(d.reader_id)],
10388 ParsedSubmessage::Heartbeat(h) => [Some(h.writer_id), Some(h.reader_id)],
10389 ParsedSubmessage::Gap(g) => [Some(g.writer_id), Some(g.reader_id)],
10390 ParsedSubmessage::AckNack(a) => [Some(a.writer_id), Some(a.reader_id)],
10391 ParsedSubmessage::NackFrag(n) => [Some(n.writer_id), Some(n.reader_id)],
10392 _ => [None, None],
10393 };
10394 ids.into_iter().flatten().any(is_secure_sedp_entity)
10395 })
10396}
10397
10398/// Protected discovery (DDS-Security §8.4.2.4) send: secure-SEDP datagrams
10399/// (DATA/HEARTBEAT/GAP of the secure writers) are
10400/// `encode_datawriter_submessage`-protected with the participant data key — the same key the peer installs via
10401/// `participant_crypto_tokens`. Non-secure SEDP goes through unchanged.
10402/// `None` ⟹ crypto error on secure SEDP → drop the datagram instead of a
10403/// plaintext leak.
10404#[cfg(feature = "security")]
10405fn protect_sedp_outbound(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
10406 let Some(gate) = rt.config.security.as_ref() else {
10407 return Some(bytes.to_vec());
10408 };
10409 if !is_secure_sedp_datagram(bytes) || bytes.len() < 20 {
10410 return Some(bytes.to_vec());
10411 }
10412 // Governance §8.4.2.4: discovery_protection_kind=NONE -> NO discovery
10413 // protection. Secure-SEDP entities (ff0003c7/ff0004c7) must then NOT
10414 // be per-endpoint-protected; otherwise their ACKNACKs leak as message-
10415 // level SEC_PREFIX with a never-exchanged per-endpoint key that a
10416 // peer (cyclone uses plain SEDP under discovery=NONE) discards as "Invalid Crypto
10417 // Handle". Pass through plain -> the outer rtps_protection
10418 // layer (SRTPS via secure_outbound_bytes) wraps the whole message correctly.
10419 if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
10420 return Some(bytes.to_vec());
10421 }
10422 // §8.4.2.4: protect BOTH directions — writer submessages (DATA/HEARTBEAT/
10423 // GAP) with the per-endpoint writer key (encode_datawriter_submessage), reader
10424 // submessages (ACKNACK/NACK_FRAG) with the per-endpoint reader key
10425 // (encode_datareader_submessage). A spec-conformant cyclone under
10426 // discovery=ENCRYPT discards a CLEAR ACKNACK of the secure-SEDP reader →
10427 // never re-sends the SubscriptionData → ZeroDDS never discovers the reader. The
10428 // per-endpoint key (same as in the sent datareader_crypto_token)
10429 // makes the ACKNACK decodable for cyclone.
10430 let subs = walk_submessages(bytes);
10431 let mut out = Vec::with_capacity(bytes.len() + 64);
10432 out.extend_from_slice(&bytes[..20]);
10433 for (id, start, total) in subs {
10434 let submsg = &bytes[start..start + total];
10435 let handle = if is_protected_writer_submessage(id) {
10436 writer_eid_in_submessage(submsg, id)
10437 .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
10438 } else if is_protected_reader_submessage(id) {
10439 reader_eid_in_submessage(submsg, id)
10440 .and_then(|r| local_endpoint_crypto_handle(rt, r, false))
10441 } else {
10442 // Framing submessage (INFO_TS/INFO_DST/...) — unchanged.
10443 out.extend_from_slice(submsg);
10444 continue;
10445 };
10446 let sec = match handle {
10447 Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
10448 // No per-endpoint handle (should not occur for secure SEDP)
10449 // → participant-key fallback, so no plaintext leak arises.
10450 None => gate.encode_data_datawriter_local(submsg),
10451 };
10452 match sec {
10453 Ok(s) => out.extend_from_slice(&s),
10454 Err(_) => return None,
10455 }
10456 }
10457 Some(out)
10458}
10459
10460/// Protects a user-reader outbound datagram (ACKNACK/NACK_FRAG) on the
10461/// send direction (DDS-Security §8.4.2.4). Counterpart to the writer-DATA layer:
10462/// under `metadata_protection != NONE` the reader submessage too MUST be protected with the
10463/// per-endpoint reader key, otherwise a spec-strict
10464/// peer writer (cyclone/FastDDS) discards the CLEAR ACKNACK → the SN gap is never
10465/// re-sent → permanent reliable stall. Only needed when
10466/// **rtps_protection** does NOT already wrap the message as an SRTPS whole; otherwise
10467/// (and with metadata=NONE) the function delegates to `secure_outbound_bytes`.
10468#[cfg(feature = "security")]
10469fn protect_user_reader_datagram<'a>(
10470 rt: &DcpsRuntime,
10471 bytes: &'a [u8],
10472) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10473 let Some(gate) = rt.config.security.as_ref() else {
10474 return Some(alloc::borrow::Cow::Borrowed(bytes));
10475 };
10476 let metadata = gate.metadata_protection().unwrap_or(ProtectionLevel::None);
10477 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
10478 // rtps != None → SRTPS wraps the whole message incl. ACKNACK; metadata ==
10479 // None → no submessage protection configured. secure_outbound_bytes
10480 // (transform_outbound) covers both cases correctly.
10481 if metadata == ProtectionLevel::None || rtps != ProtectionLevel::None || bytes.len() < 20 {
10482 return secure_outbound_bytes(rt, bytes);
10483 }
10484 let subs = walk_submessages(bytes);
10485 let mut out = Vec::with_capacity(bytes.len() + 64);
10486 out.extend_from_slice(&bytes[..20]);
10487 for (id, start, total) in subs {
10488 let submsg = &bytes[start..start + total];
10489 if is_protected_reader_submessage(id) {
10490 let handle = reader_eid_in_submessage(submsg, id)
10491 .and_then(|r| local_endpoint_crypto_handle(rt, r, false));
10492 match handle {
10493 Some(h) => match gate.encode_data_datawriter_by_handle(h, submsg) {
10494 Ok(s) => out.extend_from_slice(&s),
10495 Err(_) => return None,
10496 },
10497 // No per-endpoint reader key yet (the endpoint matches only after
10498 // secure SEDP) → pass through plaintext; the reader tick re-sends
10499 // the ACKNACK once the key is installed.
10500 None => out.extend_from_slice(submsg),
10501 }
10502 } else {
10503 // Framing submessage (INFO_DST/INFO_TS/...) — unchanged.
10504 out.extend_from_slice(submsg);
10505 }
10506 }
10507 Some(alloc::borrow::Cow::Owned(out))
10508}
10509
10510#[cfg(not(feature = "security"))]
10511fn protect_user_reader_datagram<'a>(
10512 rt: &DcpsRuntime,
10513 bytes: &'a [u8],
10514) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10515 secure_outbound_bytes(rt, bytes)
10516}
10517
10518/// `true` if `liveliness_protection != NONE` is configured — then WLP runs
10519/// over the secure entity + participant-key protection (§8.4.2.4).
10520#[cfg(feature = "security")]
10521fn wlp_liveliness_protected(rt: &DcpsRuntime) -> bool {
10522 rt.config.security.as_ref().is_some_and(|gate| {
10523 gate.liveliness_protection()
10524 .unwrap_or(ProtectionLevel::None)
10525 != ProtectionLevel::None
10526 })
10527}
10528
10529#[cfg(not(feature = "security"))]
10530fn wlp_liveliness_protected(_rt: &DcpsRuntime) -> bool {
10531 false
10532}
10533
10534/// Protects a WLP outbound datagram (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER
10535/// DATA) under `liveliness_protection != NONE` with the **participant data key**
10536/// (§8.4.2.4 / §7.4.7.1 Tab.7). WLP is participant-level (no per-endpoint key)
10537/// — analogous to the participant-key fallback in `protect_sedp_outbound`. If
10538/// `rtps_protection` already covers the message as SRTPS (or liveliness=NONE),
10539/// the function delegates to `secure_outbound_bytes`.
10540#[cfg(feature = "security")]
10541fn protect_wlp_outbound<'a>(
10542 rt: &DcpsRuntime,
10543 bytes: &'a [u8],
10544) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10545 let Some(gate) = rt.config.security.as_ref() else {
10546 return Some(alloc::borrow::Cow::Borrowed(bytes));
10547 };
10548 let live = gate
10549 .liveliness_protection()
10550 .unwrap_or(ProtectionLevel::None);
10551 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
10552 // liveliness=NONE: no inner SEC layer -> secure_outbound_bytes covers
10553 // rtps_protection (SRTPS) resp. passthrough. PREVIOUSLY this branch
10554 // also delegated with rtps!=None and thus left out the liveliness SEC -> cyclone
10555 // saw the WLP DATA "clear submsg from protected src" -> no liveliness.
10556 if live == ProtectionLevel::None || bytes.len() < 20 {
10557 return secure_outbound_bytes(rt, bytes);
10558 }
10559 let subs = walk_submessages(bytes);
10560 let mut out = Vec::with_capacity(bytes.len() + 64);
10561 out.extend_from_slice(&bytes[..20]);
10562 for (id, start, total) in subs {
10563 let submsg = &bytes[start..start + total];
10564 if id == SMID_DATA {
10565 // Protect the secure-WLP DATA with the per-endpoint key of the secure-WLP writer
10566 // (ff0200c2) — the same key ZeroDDS sends the peer via the
10567 // datawriter_crypto_token (prepare_endpoint_crypto_tokens
10568 // liveliness block). encode_data_datawriter_local took the participant
10569 // key, which cyclone does NOT associate with ff0200c2 -> undecodable ->
10570 // no liveliness -> peer approval of the user endpoints hangs.
10571 let sec = writer_eid_in_submessage(submsg, id)
10572 .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
10573 .and_then(|h| gate.encode_data_datawriter_by_handle(h, submsg).ok());
10574 match sec {
10575 Some(s) => out.extend_from_slice(&s),
10576 None => return None,
10577 }
10578 } else {
10579 out.extend_from_slice(submsg);
10580 }
10581 }
10582 // Under additional rtps_protection, message-level SRTPS MUST go around the
10583 // liveliness-SEC-wrapped WLP (both layers, like cyclone<->cyclone) —
10584 // otherwise cyclone would see only the SRTPS shell OR (with the old logic) the
10585 // clear DATA. First inner SEC (above), then SRTPS (here).
10586 if rtps != ProtectionLevel::None {
10587 return gate
10588 .transform_outbound(&out)
10589 .ok()
10590 .map(alloc::borrow::Cow::Owned);
10591 }
10592 Some(alloc::borrow::Cow::Owned(out))
10593}
10594
10595#[cfg(not(feature = "security"))]
10596fn protect_wlp_outbound<'a>(
10597 rt: &DcpsRuntime,
10598 bytes: &'a [u8],
10599) -> Option<alloc::borrow::Cow<'a, [u8]>> {
10600 secure_outbound_bytes(rt, bytes)
10601}
10602
10603/// Wire demux for the security builtin topics. Routes an
10604/// incoming RTPS submessage sequence to the `SecurityBuiltinStack`,
10605/// if the stack is active. No-op if the datagram does not address a security
10606/// builtin reader or the plugin is not enabled.
10607///
10608/// Called by the metatraffic receive path — stateless +
10609/// VolatileSecure run over the SPDP unicast locators (PID 0x0032),
10610/// not over `user_unicast`.
10611fn dispatch_security_builtin_datagram(
10612 rt: &Arc<DcpsRuntime>,
10613 bytes: &[u8],
10614 now: Duration,
10615) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
10616 // `mut` only needed on the security path (the handshake reply is appended
10617 // there); without the feature the list stays empty.
10618 #[cfg(feature = "security")]
10619 let mut outbound = Vec::new();
10620 #[cfg(not(feature = "security"))]
10621 let outbound = Vec::new();
10622 let Some(stack) = rt.security_builtin_snapshot() else {
10623 return outbound;
10624 };
10625 // Cross-vendor VolatileSecure: cyclone protects the volatile DATA as a
10626 // SEC_PREFIX/SEC_BODY/SEC_POSTFIX sequence. Before the submessage parse,
10627 // transform the sequence with the sender's Kx key (GuidPrefix = RTPS header bytes[8..20])
10628 // back to the original DATA submessage. `None` = no SEC_*
10629 // sequence (normal path) resp. crypto error.
10630 #[cfg(feature = "security")]
10631 let unprotected: Option<Vec<u8>> = if bytes.len() >= 20 {
10632 let mut pk = [0u8; 12];
10633 pk.copy_from_slice(&bytes[8..20]);
10634 unprotect_volatile_datagram(rt, bytes, &pk)
10635 } else {
10636 None
10637 };
10638 #[cfg(feature = "security")]
10639 let bytes: &[u8] = unprotected.as_deref().unwrap_or(bytes);
10640 let Ok(parsed) = decode_datagram(bytes) else {
10641 return outbound;
10642 };
10643 // sourceGuidPrefix of the datagram (DDSI-RTPS §8.3.4) — reader demux key for
10644 // the volatile builtin readers. Used in both feature configs.
10645 let remote_prefix = parsed.header.guid_prefix;
10646 let Ok(mut s) = stack.lock() else {
10647 return outbound;
10648 };
10649 for sub in parsed.submessages {
10650 match sub {
10651 ParsedSubmessage::Data(d) => {
10652 if d.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
10653 || d.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
10654 {
10655 // FU2 Gap 5: decode the stateless auth and — with
10656 // an active auth plugin — drive the handshake.
10657 // `on_stateless_message` returns the next token
10658 // message (reply/final), which we send back to the peer.
10659 // Decode errors are swallowed (stateless
10660 // has no resend path, Spec §10.3.4.1). The
10661 // completion `(remote_identity, secret)` is stored in the stack
10662 // (peer_secret) — the gate registration +
10663 // crypto-token exchange follows in Gap 6.
10664 if let Ok(msg) = s.stateless_reader.handle_data(&d) {
10665 #[cfg(feature = "security")]
10666 s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
10667 #[cfg(feature = "security")]
10668 if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, &msg) {
10669 outbound.extend(out);
10670 // FU2 S1.4: handshake done → register Kx +
10671 // send the Kx-encrypted data token to the peer over Volatile-
10672 // Secure. (the pki lock is free here:
10673 // on_stateless_message released it.)
10674 if let Some((remote_identity, secret)) = completed {
10675 if let Some(token_msg) =
10676 prepare_crypto_token(rt, remote_prefix, remote_identity, secret)
10677 {
10678 outbound.extend(protect_volatile_outbound(
10679 rt,
10680 remote_prefix,
10681 s.volatile_writer
10682 .write_with_heartbeat(&token_msg, now)
10683 .unwrap_or_default(),
10684 ));
10685 }
10686 // Step 6b: per-endpoint datawriter/datareader
10687 // tokens (per-token dedup #29: the builtins go out
10688 // here exactly once + are marked).
10689 let already = rt
10690 .endpoint_tokens_sent
10691 .read()
10692 .map(|set| set.clone())
10693 .unwrap_or_default();
10694 let pending = pending_endpoint_tokens(
10695 prepare_endpoint_crypto_tokens(rt, remote_prefix),
10696 &already,
10697 );
10698 for ep_msg in pending {
10699 let key = endpoint_token_key(&ep_msg);
10700 outbound.extend(protect_volatile_outbound(
10701 rt,
10702 remote_prefix,
10703 s.volatile_writer
10704 .write_with_heartbeat(&ep_msg, now)
10705 .unwrap_or_default(),
10706 ));
10707 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10708 set.insert(key);
10709 }
10710 }
10711 }
10712 }
10713 #[cfg(not(feature = "security"))]
10714 let _ = msg;
10715 }
10716 } else if d.reader_id
10717 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10718 {
10719 // FU2 S1.4: VolatileSecure carries the crypto-token
10720 // exchange. Kx-decrypt the received PARTICIPANT_CRYPTO_TOKENS
10721 // message + install the data key in the gate.
10722 if let Ok(_msgs) = s.volatile_reader.handle_data(remote_prefix, &d) {
10723 #[cfg(feature = "security")]
10724 for m in &_msgs {
10725 install_crypto_token(rt, remote_prefix, m);
10726 }
10727 // Step 6b: now (peer ready) send our per-endpoint
10728 // tokens back. Per-token dedup (#29): builtins
10729 // go out early here, the later-matching user-
10730 // endpoint tokens are caught up by the tick path (no per-peer
10731 // guard that blocks them forever).
10732 #[cfg(feature = "security")]
10733 {
10734 let already = rt
10735 .endpoint_tokens_sent
10736 .read()
10737 .map(|set| set.clone())
10738 .unwrap_or_default();
10739 let pending = pending_endpoint_tokens(
10740 prepare_endpoint_crypto_tokens(rt, remote_prefix),
10741 &already,
10742 );
10743 for ep_msg in pending {
10744 let key = endpoint_token_key(&ep_msg);
10745 outbound.extend(protect_volatile_outbound(
10746 rt,
10747 remote_prefix,
10748 s.volatile_writer
10749 .write_with_heartbeat(&ep_msg, now)
10750 .unwrap_or_default(),
10751 ));
10752 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10753 set.insert(key);
10754 }
10755 }
10756 }
10757 // The peer now has our participant crypto token (can
10758 // decode our SRTPS/SEC SEDP): catch up the initially dropped
10759 // SEDP burst once (OpenDDS convergence).
10760 #[cfg(feature = "security")]
10761 rt.re_announce_sedp_to_peer(remote_prefix);
10762 }
10763 }
10764 }
10765 ParsedSubmessage::DataFrag(df) => {
10766 if df.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
10767 || df.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
10768 {
10769 // FU2 cross-vendor: cyclone/FastDDS RTPS-fragment the
10770 // large HandshakeReply/Final (cert + permissions over
10771 // MTU). Reassemble the fragments + drive them through the
10772 // handshake driver like a stateless DATA.
10773 if let Ok(msgs) = s.stateless_reader.handle_data_frag(&df) {
10774 #[cfg(feature = "security")]
10775 s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
10776 #[cfg(feature = "security")]
10777 for msg in &msgs {
10778 if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, msg)
10779 {
10780 outbound.extend(out);
10781 if let Some((remote_identity, secret)) = completed {
10782 if let Some(token_msg) = prepare_crypto_token(
10783 rt,
10784 remote_prefix,
10785 remote_identity,
10786 secret,
10787 ) {
10788 outbound.extend(protect_volatile_outbound(
10789 rt,
10790 remote_prefix,
10791 s.volatile_writer
10792 .write_with_heartbeat(&token_msg, now)
10793 .unwrap_or_default(),
10794 ));
10795 }
10796 let already = rt
10797 .endpoint_tokens_sent
10798 .read()
10799 .map(|set| set.clone())
10800 .unwrap_or_default();
10801 let pending = pending_endpoint_tokens(
10802 prepare_endpoint_crypto_tokens(rt, remote_prefix),
10803 &already,
10804 );
10805 for ep_msg in pending {
10806 let key = endpoint_token_key(&ep_msg);
10807 outbound.extend(protect_volatile_outbound(
10808 rt,
10809 remote_prefix,
10810 s.volatile_writer
10811 .write_with_heartbeat(&ep_msg, now)
10812 .unwrap_or_default(),
10813 ));
10814 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10815 set.insert(key);
10816 }
10817 }
10818 }
10819 }
10820 }
10821 #[cfg(not(feature = "security"))]
10822 let _ = msgs;
10823 }
10824 } else if df.reader_id
10825 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10826 {
10827 let _ = s.volatile_reader.handle_data_frag(remote_prefix, &df, now);
10828 }
10829 }
10830 ParsedSubmessage::Heartbeat(h) => {
10831 let to_volatile_reader = h.reader_id
10832 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10833 || (h.reader_id == EntityId::UNKNOWN
10834 && h.writer_id
10835 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER);
10836 if to_volatile_reader {
10837 s.volatile_reader.handle_heartbeat(remote_prefix, &h, now);
10838 }
10839 }
10840 ParsedSubmessage::Gap(g) => {
10841 if g.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
10842 let _ = s.volatile_reader.handle_gap(remote_prefix, &g);
10843 }
10844 }
10845 ParsedSubmessage::AckNack(ack) => {
10846 if ack.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
10847 let base = ack.reader_sn_state.bitmap_base;
10848 let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
10849 let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
10850 s.volatile_writer.handle_acknack(src, base, requested);
10851 }
10852 }
10853 ParsedSubmessage::NackFrag(nf) => {
10854 if nf.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
10855 let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
10856 s.volatile_writer.handle_nackfrag(src, &nf);
10857 }
10858 }
10859 _ => {}
10860 }
10861 }
10862 outbound
10863}
10864
10865/// Dispatches a datagram addressed to the TypeLookup service endpoints
10866/// (XTypes 1.3 §7.6.3.3.4). Handles incoming
10867/// requests (to `TL_SVC_REQ_READER`), generates replies and sends
10868/// them back to the source locator; handles incoming replies
10869/// (to `TL_SVC_REPLY_READER`), correlates with the client.
10870///
10871/// Returns `true` if the datagram was accepted by the TypeLookup path
10872/// — the caller can then skip the user-reader path.
10873fn dispatch_type_lookup_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], source: &Locator) -> bool {
10874 use zerodds_cdr::{BufferReader, Endianness};
10875 use zerodds_rtps::inline_qos::{SampleIdentityBytes, find_related_sample_identity};
10876 use zerodds_types::type_lookup::{
10877 GetTypeDependenciesReply, GetTypeDependenciesRequest, GetTypesReply, GetTypesRequest,
10878 };
10879
10880 let Ok(parsed) = decode_datagram(bytes) else {
10881 return false;
10882 };
10883 // DDS-RPC §7.8.2: the request sample identity = (request writer GUID,
10884 // request SN). The server carries it as PID_RELATED_SAMPLE_IDENTITY in the
10885 // reply inline QoS, so a client (also cross-vendor) can correlate
10886 // without relying on the echoed writer_sn.
10887 let src_prefix = parsed.header.guid_prefix;
10888
10889 let mut accepted = false;
10890
10891 for sub in &parsed.submessages {
10892 let ParsedSubmessage::Data(d) = sub else {
10893 continue;
10894 };
10895 let payload: &[u8] = &d.serialized_payload;
10896 if payload.is_empty() {
10897 continue;
10898 }
10899 // Skip CDR-Encapsulation header (4 bytes) if present.
10900 let body: &[u8] = if payload.len() >= 4 && (payload[0] == 0x00 && payload[1] == 0x01) {
10901 &payload[4..]
10902 } else {
10903 payload
10904 };
10905
10906 // Inbound Request → Server.
10907 if d.reader_id == EntityId::TL_SVC_REQ_READER {
10908 accepted = true;
10909 // Request sample identity = (request writer GUID, request SN) — mirrored
10910 // as related_sample_identity into the reply inline QoS.
10911 let (sn_hi, sn_lo) = d.writer_sn.split();
10912 let req_sn = ((u64::from(sn_hi as u32)) << 32) | u64::from(sn_lo);
10913 let related =
10914 SampleIdentityBytes::new(Guid::new(src_prefix, d.writer_id).to_bytes(), req_sn);
10915 // Try GetTypes-Request first; fall back to
10916 // GetTypeDependenciesRequest if that fails.
10917 let mut r = BufferReader::new(body, Endianness::Little);
10918 if let Ok(req) = GetTypesRequest::decode_from(&mut r) {
10919 let reply = match rt.type_lookup_server.lock() {
10920 Ok(g) => g.handle_get_types(&req),
10921 Err(_) => continue,
10922 };
10923 let _ = send_type_lookup_reply(
10924 rt,
10925 source,
10926 TypeLookupReplyPayload::Types(reply),
10927 related,
10928 );
10929 continue;
10930 }
10931 let mut r = BufferReader::new(body, Endianness::Little);
10932 if let Ok(req) = GetTypeDependenciesRequest::decode_from(&mut r) {
10933 let reply = match rt.type_lookup_server.lock() {
10934 Ok(g) => g.handle_get_type_dependencies(&req),
10935 Err(_) => continue,
10936 };
10937 let _ = send_type_lookup_reply(
10938 rt,
10939 source,
10940 TypeLookupReplyPayload::Dependencies(reply),
10941 related,
10942 );
10943 continue;
10944 }
10945 }
10946
10947 // Inbound Reply → Client.
10948 if d.reader_id == EntityId::TL_SVC_REPLY_READER {
10949 accepted = true;
10950 // Correlation prefers PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2,
10951 // cross-vendor compatible); fallback to the echoed writer_sn for
10952 // peers/legacy replies without inline QoS.
10953 let request_id = d
10954 .inline_qos
10955 .as_ref()
10956 .and_then(|pl| find_related_sample_identity(pl, true).ok().flatten())
10957 .map(|sid| zerodds_discovery::type_lookup::RequestId::from_u64(sid.sequence_number))
10958 .unwrap_or_else(|| {
10959 let (sn_high, sn_low) = d.writer_sn.split();
10960 let sn_u64 = ((u64::from(sn_high as u32)) << 32) | u64::from(sn_low);
10961 zerodds_discovery::type_lookup::RequestId::from_u64(sn_u64)
10962 });
10963 let mut r = BufferReader::new(body, Endianness::Little);
10964 if let Ok(reply) = GetTypesReply::decode_from(&mut r) {
10965 if let Ok(mut client) = rt.type_lookup_client.lock() {
10966 client.handle_reply(request_id, TypeLookupReply::Types(reply));
10967 }
10968 continue;
10969 }
10970 // M-5: the getTypeDependencies reply carries a different element type
10971 // (TypeIdentifierWithSize list) — its own decode branch, otherwise the
10972 // dependencies callback never fires.
10973 let mut r = BufferReader::new(body, Endianness::Little);
10974 if let Ok(reply) = GetTypeDependenciesReply::decode_from(&mut r) {
10975 if let Ok(mut client) = rt.type_lookup_client.lock() {
10976 client.handle_reply(request_id, TypeLookupReply::Dependencies(reply));
10977 }
10978 continue;
10979 }
10980 }
10981 }
10982
10983 accepted
10984}
10985
10986/// Reply payload variants that the TypeLookup server can emit.
10987enum TypeLookupReplyPayload {
10988 Types(zerodds_types::type_lookup::GetTypesReply),
10989 Dependencies(zerodds_types::type_lookup::GetTypeDependenciesReply),
10990}
10991
10992/// Sends a TypeLookup reply to a peer locator as a
10993/// DATA datagram on the TL_SVC_REPLY_WRITER → peer's
10994/// TL_SVC_REPLY_READER. The sequence number echoes the request sequence
10995/// for correlation purposes (see XTypes §7.6.3.3.3 sample identity).
10996fn send_type_lookup_reply(
10997 rt: &Arc<DcpsRuntime>,
10998 target: &Locator,
10999 reply: TypeLookupReplyPayload,
11000 related: zerodds_rtps::inline_qos::SampleIdentityBytes,
11001) -> Result<()> {
11002 use alloc::sync::Arc as AllocArc;
11003 use core::sync::atomic::Ordering;
11004 use zerodds_cdr::{BufferWriter, Endianness};
11005 use zerodds_rtps::datagram::encode_data_datagram;
11006 use zerodds_rtps::header::RtpsHeader;
11007 use zerodds_rtps::submessages::DataSubmessage;
11008 use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber, VendorId};
11009
11010 // CDR-encode reply (PL_CDR_LE-Encapsulation).
11011 let mut w = BufferWriter::new(Endianness::Little);
11012 match reply {
11013 TypeLookupReplyPayload::Types(r) => {
11014 r.encode_into(&mut w)
11015 .map_err(|_| DdsError::PreconditionNotMet {
11016 reason: "type_lookup reply encode failed",
11017 })?;
11018 }
11019 TypeLookupReplyPayload::Dependencies(r) => {
11020 r.encode_into(&mut w)
11021 .map_err(|_| DdsError::PreconditionNotMet {
11022 reason: "type_lookup deps reply encode failed",
11023 })?;
11024 }
11025 }
11026 let body = w.into_bytes();
11027 let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
11028 payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
11029 payload.extend_from_slice(&body);
11030
11031 let header = RtpsHeader {
11032 protocol_version: ProtocolVersion::CURRENT,
11033 vendor_id: VendorId::ZERODDS,
11034 guid_prefix: rt.guid_prefix,
11035 };
11036 // Own monotonically increasing reply-writer SN (starting at 1) instead of a
11037 // request-SN echo — a reliable cross-vendor reply reader would otherwise see SN jumps.
11038 let reply_sn = rt
11039 .tl_reply_sn
11040 .fetch_add(1, Ordering::Relaxed)
11041 .wrapping_add(1);
11042 let writer_sn =
11043 SequenceNumber::from_high_low((reply_sn >> 32) as i32, (reply_sn & 0xFFFF_FFFF) as u32);
11044 let data = DataSubmessage {
11045 extra_flags: 0,
11046 reader_id: EntityId::TL_SVC_REPLY_READER,
11047 writer_id: EntityId::TL_SVC_REPLY_WRITER,
11048 writer_sn,
11049 // DDS-RPC §7.8.2: related_sample_identity couples the reply to the
11050 // request (cross-vendor correlation without a writer_sn echo).
11051 inline_qos: Some(zerodds_rtps::inline_qos::reply_inline_qos(related, true)),
11052 key_flag: false,
11053 non_standard_flag: false,
11054 serialized_payload: AllocArc::from(payload.into_boxed_slice()),
11055 };
11056 let datagram =
11057 encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
11058 reason: "type_lookup reply datagram encode failed",
11059 })?;
11060
11061 if is_routable_user_locator(target) {
11062 let _ = rt.user_unicast.send(target, &datagram);
11063 }
11064 Ok(())
11065}
11066
11067/// Sends a discovery datagram to all target locators. UDP-only
11068/// (TCPv4/SHM/UDS are not carried in discovery); non-UDP
11069/// locators are silently ignored.
11070fn send_discovery_datagram(rt: &Arc<DcpsRuntime>, targets: &[Locator], bytes: &[u8]) {
11071 let Some(secured) = secure_outbound_bytes(rt, bytes) else {
11072 return;
11073 };
11074 for t in targets {
11075 if !is_routable_user_locator(t) {
11076 continue;
11077 }
11078 // Send unicast metatraffic (SEDP responses, VolatileSecure, stateless auth)
11079 // from the **metatraffic recv socket** (`spdp_unicast`, = announced
11080 // metatraffic_unicast_locator), NOT from the ephemeral `spdp_mc_tx`.
11081 // Otherwise the peer sees a foreign source port and sends its
11082 // responses (e.g. cyclone's VolatileSecure ACKNACK to the source locator)
11083 // to a port ZeroDDS does not listen on → reliable resends stay
11084 // out (cross-vendor). `spdp_mc_tx` stays only for SPDP multicast.
11085 let _ = rt.spdp_unicast.send(t, &secured);
11086 }
11087}
11088
11089/// Default user-multicast locator for a DomainParticipant.
11090/// Not used in live mode 1 yet; SPDP-announced in B2.
11091#[must_use]
11092pub fn user_multicast_endpoint(domain_id: i32) -> SocketAddr {
11093 // Spec §9.6.1.4.1: user-multicast-port = PB + DG * d + d2
11094 // = 7400 + 250 * d + 1
11095 let port = 7400u16.saturating_add(250u16.saturating_mul(domain_id as u16).saturating_add(1));
11096 SocketAddr::from((Ipv4Addr::from([239, 255, 0, 1]), port))
11097}
11098
11099#[cfg(test)]
11100#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
11101mod tests {
11102 use super::*;
11103
11104 /// A's the last mile of the big-endian decode path: a big-endian
11105 /// encapsulation header on a received DATA sample must route the typed
11106 /// decode to `DdsType::decode_be`, not `decode`. Builds a CDR2_BE wire
11107 /// sample, runs it through `delivered_to_user_sample` (the real recv→sample
11108 /// conversion), and asserts the resulting `big_endian` flag drives a correct
11109 /// decode — while the little-endian `decode` on the same BE body is wrong.
11110 #[test]
11111 fn big_endian_encap_routes_to_decode_be() {
11112 use crate::dds_type::{DdsType, DecodeError, EncodeError};
11113 #[derive(Debug, PartialEq, Clone)]
11114 struct BeProbe {
11115 v: i32,
11116 }
11117 impl DdsType for BeProbe {
11118 const TYPE_NAME: &'static str = "BeProbe";
11119 fn encode(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
11120 let mut w = zerodds_cdr::BufferWriter::new(zerodds_cdr::Endianness::Little).xcdr2();
11121 <i32 as zerodds_cdr::CdrEncode>::encode(&self.v, &mut w)?;
11122 out.extend_from_slice(&w.into_bytes());
11123 Ok(())
11124 }
11125 fn encode_be(&self, out: &mut Vec<u8>) -> core::result::Result<(), EncodeError> {
11126 let mut w = zerodds_cdr::BufferWriter::new(zerodds_cdr::Endianness::Big).xcdr2();
11127 <i32 as zerodds_cdr::CdrEncode>::encode(&self.v, &mut w)?;
11128 out.extend_from_slice(&w.into_bytes());
11129 Ok(())
11130 }
11131 fn decode(b: &[u8]) -> core::result::Result<Self, DecodeError> {
11132 let mut r =
11133 zerodds_cdr::BufferReader::new(b, zerodds_cdr::Endianness::Little).xcdr2();
11134 Ok(BeProbe {
11135 v: <i32 as zerodds_cdr::CdrDecode>::decode(&mut r)?,
11136 })
11137 }
11138 fn decode_be(b: &[u8]) -> core::result::Result<Self, DecodeError> {
11139 let mut r = zerodds_cdr::BufferReader::new(b, zerodds_cdr::Endianness::Big).xcdr2();
11140 Ok(BeProbe {
11141 v: <i32 as zerodds_cdr::CdrDecode>::decode(&mut r)?,
11142 })
11143 }
11144 }
11145
11146 // A value whose 4 LE bytes differ from its 4 BE bytes.
11147 let orig = BeProbe { v: 0x0102_0304 };
11148 let strengths = alloc::collections::BTreeMap::new();
11149
11150 let mk = |repr_lo: u8, body: Vec<u8>| {
11151 let mut wire = alloc::vec![0x00u8, repr_lo, 0x00, 0x00];
11152 wire.extend_from_slice(&body);
11153 zerodds_rtps::reliable_reader::DeliveredSample {
11154 writer_guid: Guid::new(GuidPrefix::from_bytes([0x11; 12]), EntityId::PARTICIPANT),
11155 sequence_number: zerodds_rtps::wire_types::SequenceNumber(1),
11156 payload: alloc::sync::Arc::from(wire.into_boxed_slice()),
11157 kind: zerodds_rtps::history_cache::ChangeKind::Alive,
11158 key_hash: None,
11159 source_timestamp: None,
11160 }
11161 };
11162
11163 // --- big-endian wire: CDR2_BE (repr low byte 0x06) ---
11164 let mut be_body = Vec::new();
11165 orig.encode_be(&mut be_body).unwrap();
11166 let us = delivered_to_user_sample(&mk(0x06, be_body), &strengths).expect("alive");
11167 let UserSample::Alive {
11168 payload,
11169 big_endian,
11170 representation,
11171 ..
11172 } = us
11173 else {
11174 panic!("expected Alive");
11175 };
11176 assert!(big_endian, "CDR2_BE encap must set big_endian");
11177 assert_eq!(representation, 1, "0x06 = XCDR2");
11178 // The subscriber dispatch: decode_be for a big-endian sample.
11179 let decoded = if big_endian {
11180 BeProbe::decode_be(&payload)
11181 } else {
11182 BeProbe::decode(&payload)
11183 }
11184 .unwrap();
11185 assert_eq!(decoded, orig, "BE wire decodes correctly via decode_be");
11186 // The dispatch matters: little-endian decode on the BE body is wrong.
11187 assert_ne!(BeProbe::decode(&payload).unwrap(), orig);
11188
11189 // --- little-endian control: CDR2_LE (repr low byte 0x07) ---
11190 let mut le_body = Vec::new();
11191 orig.encode(&mut le_body).unwrap();
11192 let us_le = delivered_to_user_sample(&mk(0x07, le_body), &strengths).expect("alive");
11193 let UserSample::Alive {
11194 payload: le_payload,
11195 big_endian: be_le,
11196 ..
11197 } = us_le
11198 else {
11199 panic!("expected Alive");
11200 };
11201 assert!(!be_le, "CDR2_LE encap must NOT set big_endian");
11202 assert_eq!(BeProbe::decode(&le_payload).unwrap(), orig);
11203 }
11204
11205 /// FU1 diagnosis: inject a REAL FastDDS-3.6 SPDP datagram (domain 205,
11206 /// codepit capture 2026-05-29) directly into handle_spdp_datagram
11207 /// — does the runtime register FastDDS as a peer? Separates the
11208 /// receive problem (socket) from the handle problem (parse/insert/filter).
11209 #[test]
11210 fn handle_spdp_registers_real_fastdds_participant() {
11211 fn hx(s: &str) -> Vec<u8> {
11212 (0..s.len())
11213 .step_by(2)
11214 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
11215 .collect()
11216 }
11217 const FASTDDS_SPDP: &str = "525450530203010f010fa72bbbebc90100000000090108006850196a57e882b41505b80100001000000100c7000100c2000000000100000000030000150004000203000016000400010f000000800400030601000f000400cd00000050001000010fa72bbbebc90100000000000001c107800400010000000380280021000000653830653630353335646339343432636231306537323239313038653135666600000000320018000100000024e50000000000000000000000000000c0a8b273310018000100000025e50000000000000000000000000000c0a8b273020008001400000000000000580004003ffc0f006200140010000000525450535061727469636970616e74005900cc0004000000110000005041525449434950414e545f54595045000000000700000053494d504c4500001b000000666173746464732e706879736963616c5f646174612e686f73740000210000006538306536303533356463393434326362313065373232393130386531356666000000001b000000666173746464732e706879736963616c5f646174612e75736572000005000000726f6f74000000001e000000666173746464732e706879736963616c5f646174612e70726f636573730000000600000036303334370000000100000080013800010000001ae50000000000000000000000000000efff00016850196a72ca8cb4020000000000000030040000000000000000000000000000";
11218 let bytes = hx(FASTDDS_SPDP);
11219 let prefix = GuidPrefix::from_bytes([0x99; 12]);
11220 let rt =
11221 Arc::new(DcpsRuntime::start(205, prefix, RuntimeConfig::default()).expect("rt start"));
11222 assert_eq!(rt.discovered_participants().len(), 0, "fresh: no peers");
11223 handle_spdp_datagram_for_test(&rt, &bytes);
11224 let n = rt.discovered_participants().len();
11225 assert_eq!(
11226 n, 1,
11227 "FastDDS must be registered after handle_spdp_datagram (got {n})"
11228 );
11229 }
11230
11231 #[test]
11232 fn select_user_transport_tcpv4_yields_tcpv4_locator() {
11233 let prefix = GuidPrefix::from_bytes([1u8; 12]);
11234 let (t, accept) =
11235 select_user_transport(UserTransportKind::TcpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
11236 .expect("TcpV4 transport");
11237 assert_eq!(t.local_locator().kind, LocatorKind::Tcpv4);
11238 assert!(accept.is_some(), "TCP needs an accept handle");
11239 }
11240
11241 #[test]
11242 fn select_user_transport_udpv4_default_kind() {
11243 let prefix = GuidPrefix::from_bytes([2u8; 12]);
11244 let (t, accept) =
11245 select_user_transport(UserTransportKind::UdpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
11246 .expect("UdpV4 transport");
11247 assert_eq!(t.local_locator().kind, LocatorKind::UdpV4);
11248 assert!(accept.is_none(), "UDP needs no accept handle");
11249 }
11250
11251 #[cfg(feature = "same-host-uds")]
11252 #[test]
11253 fn select_user_transport_uds_yields_uds_locator() {
11254 let prefix = GuidPrefix::from_bytes([3u8; 12]);
11255 let (t, accept) =
11256 select_user_transport(UserTransportKind::Uds, prefix, 0, Ipv4Addr::UNSPECIFIED)
11257 .expect("Uds transport");
11258 assert_eq!(t.local_locator().kind, LocatorKind::Uds);
11259 assert!(accept.is_none(), "UDS needs no accept handle");
11260 }
11261
11262 #[test]
11263 fn strip_user_encap_xcdr2_le() {
11264 let payload = [0x00, 0x07, 0x00, 0x00, 1, 2, 3];
11265 assert_eq!(strip_user_encap(&payload), Some(alloc::vec![1, 2, 3]));
11266 }
11267
11268 #[test]
11269 fn strip_user_encap_xcdr1_le() {
11270 // Cyclone default for simple types.
11271 let payload = [0x00, 0x01, 0x00, 0x00, 0xAA];
11272 assert_eq!(strip_user_encap(&payload), Some(alloc::vec![0xAA]));
11273 }
11274
11275 #[test]
11276 fn strip_user_encap_rejects_unknown_scheme() {
11277 let payload = [0xFF, 0xFF, 0x00, 0x00, 1];
11278 assert_eq!(strip_user_encap(&payload), None);
11279 }
11280
11281 #[test]
11282 fn strip_user_encap_rejects_short() {
11283 assert_eq!(strip_user_encap(&[0x00, 0x07]), None);
11284 }
11285
11286 #[test]
11287 fn user_payload_encap_is_cdr_le() {
11288 // CDR_LE (PLAIN_CDR / XCDR1, Little-Endian) — ehrliche
11289 // Declaration of the body encoding generated by codegen.
11290 assert_eq!(USER_PAYLOAD_ENCAP, [0x00, 0x01, 0x00, 0x00]);
11291 }
11292
11293 #[test]
11294 fn data_repr_offer_str_uses_spec_ids() {
11295 use zerodds_rtps::publication_data::data_representation as dr;
11296 // XCDR1 -> Spec-Id 0 (NICHT 1 = XML); XCDR2 -> 2.
11297 assert_eq!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XCDR]));
11298 assert_eq!(parse_data_repr_offer_str("XCDR2"), Some(vec![dr::XCDR2]));
11299 assert_eq!(parse_data_repr_offer_str("xcdr2"), Some(vec![dr::XCDR2]));
11300 assert_eq!(
11301 parse_data_repr_offer_str("XCDR2,XCDR1"),
11302 Some(vec![dr::XCDR2, dr::XCDR])
11303 );
11304 assert_eq!(parse_data_repr_offer_str("bogus"), None);
11305 assert_eq!(parse_data_repr_offer_str(""), None);
11306 // XCDR1 must NOT map to the XML id (1).
11307 assert_ne!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XML]));
11308 }
11309
11310 /// A DataReader announces every representation it can decode (XCDR2 + XCDR1)
11311 /// — XTypes 1.3 §7.6.2: the default reader policy accepts both. CycloneDDS
11312 /// (and legacy RTI / OpenDDS < 3.16) default their writers to XCDR1 for
11313 /// `@final` types; without XCDR1 in the reader's announced set those writers
11314 /// fail the DataRepresentation RxO check and never deliver. Regression for
11315 /// Bug DR1.
11316 #[test]
11317 fn reader_accept_repr_always_includes_both_representations() {
11318 use zerodds_rtps::publication_data::data_representation as dr;
11319 // Default writer offer [XCDR2] -> reader must also accept XCDR1.
11320 let widened = reader_accept_repr(&[dr::XCDR2]);
11321 assert!(widened.contains(&dr::XCDR2));
11322 assert!(widened.contains(&dr::XCDR));
11323 // XCDR2 stays first (the preferred / generated encoding).
11324 assert_eq!(widened[0], dr::XCDR2);
11325 // Already-both list is preserved (idempotent, order kept).
11326 assert_eq!(
11327 reader_accept_repr(&[dr::XCDR, dr::XCDR2]),
11328 alloc::vec![dr::XCDR, dr::XCDR2]
11329 );
11330 // Empty config still yields both.
11331 let from_empty = reader_accept_repr(&[]);
11332 assert!(from_empty.contains(&dr::XCDR2) && from_empty.contains(&dr::XCDR));
11333 }
11334
11335 #[test]
11336 fn user_payload_encap_maps_repr_and_extensibility() {
11337 use zerodds_rtps::publication_data::data_representation as dr;
11338 use zerodds_types::qos::ExtensibilityForRepr as Ext;
11339 // DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 Encapsulation-IDs
11340 // (2-byte repr-id BE + 2-byte options=0), little-endian variant:
11341 // XCDR1 final/appendable -> CDR_LE 0x0001
11342 // XCDR1 mutable -> PL_CDR_LE 0x0003
11343 // XCDR2 final -> PLAIN_CDR2_LE 0x0007
11344 // XCDR2 appendable -> D_CDR2_LE 0x0009
11345 // XCDR2 mutable -> PL_CDR2_LE 0x000b
11346 assert_eq!(
11347 user_payload_encap(dr::XCDR, Ext::Final, false),
11348 [0x00, 0x01, 0x00, 0x00]
11349 );
11350 assert_eq!(
11351 user_payload_encap(dr::XCDR, Ext::Appendable, false),
11352 [0x00, 0x01, 0x00, 0x00]
11353 );
11354 assert_eq!(
11355 user_payload_encap(dr::XCDR, Ext::Mutable, false),
11356 [0x00, 0x03, 0x00, 0x00]
11357 );
11358 assert_eq!(
11359 user_payload_encap(dr::XCDR2, Ext::Final, false),
11360 [0x00, 0x07, 0x00, 0x00]
11361 );
11362 assert_eq!(
11363 user_payload_encap(dr::XCDR2, Ext::Appendable, false),
11364 [0x00, 0x09, 0x00, 0x00]
11365 );
11366 assert_eq!(
11367 user_payload_encap(dr::XCDR2, Ext::Mutable, false),
11368 [0x00, 0x0b, 0x00, 0x00]
11369 );
11370 // The default const is exactly the (XCDR1, Final) case.
11371 assert_eq!(
11372 user_payload_encap(dr::XCDR, Ext::Final, false),
11373 USER_PAYLOAD_ENCAP
11374 );
11375 // Unknown/XML repr falls back safely to CDR_LE.
11376 assert_eq!(
11377 user_payload_encap(dr::XML, Ext::Final, false),
11378 [0x00, 0x01, 0x00, 0x00]
11379 );
11380 // big_endian=true selects the `_BE` variant (the even predecessor of
11381 // the odd `_LE` id): CDR_BE 0x00, PL_CDR_BE 0x02, PLAIN_CDR2_BE 0x06,
11382 // D_CDR2_BE 0x08, PL_CDR2_BE 0x0a (RTPS 2.5 §10.5). Used by the
11383 // durability service to replay a big-endian peer's stored sample.
11384 assert_eq!(
11385 user_payload_encap(dr::XCDR, Ext::Final, true),
11386 [0x00, 0x00, 0x00, 0x00]
11387 );
11388 assert_eq!(
11389 user_payload_encap(dr::XCDR, Ext::Mutable, true),
11390 [0x00, 0x02, 0x00, 0x00]
11391 );
11392 assert_eq!(
11393 user_payload_encap(dr::XCDR2, Ext::Final, true),
11394 [0x00, 0x06, 0x00, 0x00]
11395 );
11396 assert_eq!(
11397 user_payload_encap(dr::XCDR2, Ext::Appendable, true),
11398 [0x00, 0x08, 0x00, 0x00]
11399 );
11400 assert_eq!(
11401 user_payload_encap(dr::XCDR2, Ext::Mutable, true),
11402 [0x00, 0x0a, 0x00, 0x00]
11403 );
11404 }
11405
11406 #[test]
11407 fn observability_sink_records_writer_and_reader_creation() {
11408 // VecSink injizieren, Writer + Reader erzeugen,
11409 // check that both events arrive.
11410 use std::sync::Arc as StdArc;
11411 use zerodds_foundation::observability::{Component, Level, VecSink};
11412
11413 let sink = StdArc::new(VecSink::new());
11414 let cfg = RuntimeConfig {
11415 observability: sink.clone(),
11416 ..RuntimeConfig::default()
11417 };
11418 let rt =
11419 DcpsRuntime::start(7, GuidPrefix::from_bytes([0xAA; 12]), cfg).expect("start runtime");
11420 let _ = rt.register_user_writer(UserWriterConfig {
11421 topic_name: "ObsTopic".into(),
11422 type_name: "ObsType".into(),
11423 reliable: true,
11424 durability: zerodds_qos::DurabilityKind::Volatile,
11425 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11426 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11427 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11428 ownership: zerodds_qos::OwnershipKind::Shared,
11429 ownership_strength: 0,
11430 partition: alloc::vec![],
11431 user_data: alloc::vec![],
11432 topic_data: alloc::vec![],
11433 group_data: alloc::vec![],
11434 type_identifier: zerodds_types::TypeIdentifier::None,
11435 data_representation_offer: None,
11436 });
11437 let _ = rt.register_user_reader(UserReaderConfig {
11438 topic_name: "ObsTopic".into(),
11439 type_name: "ObsType".into(),
11440 reliable: true,
11441 durability: zerodds_qos::DurabilityKind::Volatile,
11442 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11443 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11444 ownership: zerodds_qos::OwnershipKind::Shared,
11445 partition: alloc::vec![],
11446 user_data: alloc::vec![],
11447 topic_data: alloc::vec![],
11448 group_data: alloc::vec![],
11449 type_identifier: zerodds_types::TypeIdentifier::None,
11450 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11451 data_representation_offer: None,
11452 });
11453 rt.shutdown();
11454
11455 let events = sink.snapshot();
11456 assert!(
11457 events.iter().any(|e| e.name == "user_writer.created"
11458 && e.component == Component::Dcps
11459 && e.level == Level::Info),
11460 "writer-event missing: got {:?}",
11461 events.iter().map(|e| e.name).collect::<Vec<_>>()
11462 );
11463 assert!(
11464 events
11465 .iter()
11466 .any(|e| e.name == "user_reader.created" && e.component == Component::Dcps),
11467 "reader-event missing"
11468 );
11469 // The topic attribute must hang on the writer.created event.
11470 let writer_event = events
11471 .iter()
11472 .find(|e| e.name == "user_writer.created")
11473 .expect("writer event");
11474 assert!(
11475 writer_event
11476 .attrs
11477 .iter()
11478 .any(|a| a.key == "topic" && a.value == "ObsTopic"),
11479 "topic attr missing"
11480 );
11481 }
11482
11483 #[test]
11484 fn user_endpoint_entity_kind_follows_keyedness() {
11485 // Regression (ROS-2 cross-vendor): the entityKind of a user
11486 // endpoint MUST follow the type keyedness (Spec §9.3.1.2). A
11487 // a keyless type yields NoKey (Writer 0x03 / Reader 0x04), a
11488 // keyed type WithKey (0x02 / 0x07). If this does not match the
11489 // peer, CycloneDDS/ROS 2 silently rejects the endpoint match
11490 // (DDS_INVALID_QOS_POLICY_ID, no log). create_datawriter/
11491 // create_datareader derive `is_keyed` from `DdsType::HAS_KEY`.
11492 use zerodds_rtps::wire_types::EntityKind;
11493 let rt = DcpsRuntime::start(
11494 11,
11495 GuidPrefix::from_bytes([0xBC; 12]),
11496 RuntimeConfig::default(),
11497 )
11498 .expect("start runtime");
11499 let mk_w = || UserWriterConfig {
11500 topic_name: "KindTopic".into(),
11501 type_name: "KindType".into(),
11502 reliable: true,
11503 durability: zerodds_qos::DurabilityKind::Volatile,
11504 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11505 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11506 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11507 ownership: zerodds_qos::OwnershipKind::Shared,
11508 ownership_strength: 0,
11509 partition: alloc::vec![],
11510 user_data: alloc::vec![],
11511 topic_data: alloc::vec![],
11512 group_data: alloc::vec![],
11513 type_identifier: zerodds_types::TypeIdentifier::None,
11514 data_representation_offer: None,
11515 };
11516 let mk_r = || UserReaderConfig {
11517 topic_name: "KindTopic".into(),
11518 type_name: "KindType".into(),
11519 reliable: true,
11520 durability: zerodds_qos::DurabilityKind::Volatile,
11521 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11522 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11523 ownership: zerodds_qos::OwnershipKind::Shared,
11524 partition: alloc::vec![],
11525 user_data: alloc::vec![],
11526 topic_data: alloc::vec![],
11527 group_data: alloc::vec![],
11528 type_identifier: zerodds_types::TypeIdentifier::None,
11529 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11530 data_representation_offer: None,
11531 };
11532 // keyless (HAS_KEY=false) -> NoKey
11533 let w_nokey = rt.register_user_writer_kind(mk_w(), false).expect("writer");
11534 assert_eq!(w_nokey.entity_kind, EntityKind::UserWriterNoKey);
11535 let (r_nokey, _) = rt.register_user_reader_kind(mk_r(), false).expect("reader");
11536 assert_eq!(r_nokey.entity_kind, EntityKind::UserReaderNoKey);
11537 // keyed (HAS_KEY=true) -> WithKey
11538 let w_key = rt.register_user_writer_kind(mk_w(), true).expect("writer");
11539 assert_eq!(w_key.entity_kind, EntityKind::UserWriterWithKey);
11540 let (r_key, _) = rt.register_user_reader_kind(mk_r(), true).expect("reader");
11541 assert_eq!(r_key.entity_kind, EntityKind::UserReaderWithKey);
11542 rt.shutdown();
11543 }
11544
11545 #[test]
11546 fn incompatible_qos_match_emits_loud_warning() {
11547 // C2 "loud instead of silent": an incompatible QoS match is logged as a
11548 // warn event with topic + policy, not silently discarded.
11549 // Setup: writer Volatile + reader TransientLocal on the same
11550 // Topic (reader requests more durability than the writer offers)
11551 // → intra-runtime match fails with policy DURABILITY.
11552 use std::sync::Arc as StdArc;
11553 use zerodds_foundation::observability::{Component, Level, VecSink};
11554
11555 let sink = StdArc::new(VecSink::new());
11556 let cfg_a = RuntimeConfig {
11557 observability: sink.clone(),
11558 tick_period: Duration::from_millis(5),
11559 ..RuntimeConfig::default()
11560 };
11561 let cfg_b = RuntimeConfig {
11562 tick_period: Duration::from_millis(5),
11563 ..RuntimeConfig::default()
11564 };
11565 // Two same-process runtimes, same domain → inproc discovery.
11566 let rt = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCE; 12]), cfg_a)
11567 .expect("start runtime a");
11568 let rt_b = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCF; 12]), cfg_b)
11569 .expect("start runtime b");
11570 let _w = rt
11571 .register_user_writer(UserWriterConfig {
11572 topic_name: "QT".into(),
11573 type_name: "QType".into(),
11574 reliable: false,
11575 durability: zerodds_qos::DurabilityKind::Volatile,
11576 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11577 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11578 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11579 ownership: zerodds_qos::OwnershipKind::Shared,
11580 ownership_strength: 0,
11581 partition: alloc::vec![],
11582 user_data: alloc::vec![],
11583 topic_data: alloc::vec![],
11584 group_data: alloc::vec![],
11585 type_identifier: zerodds_types::TypeIdentifier::None,
11586 data_representation_offer: None,
11587 })
11588 .expect("writer");
11589 let _r = rt_b
11590 .register_user_reader(UserReaderConfig {
11591 topic_name: "QT".into(),
11592 type_name: "QType".into(),
11593 reliable: false,
11594 durability: zerodds_qos::DurabilityKind::TransientLocal,
11595 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11596 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11597 ownership: zerodds_qos::OwnershipKind::Shared,
11598 partition: alloc::vec![],
11599 user_data: alloc::vec![],
11600 topic_data: alloc::vec![],
11601 group_data: alloc::vec![],
11602 type_identifier: zerodds_types::TypeIdentifier::None,
11603 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11604 data_representation_offer: None,
11605 })
11606 .expect("reader");
11607 // Await the match pass.
11608 let mut found = false;
11609 for _ in 0..40 {
11610 std::thread::sleep(Duration::from_millis(25));
11611 let events = sink.snapshot();
11612 if events.iter().any(|e| {
11613 (e.name == "qos.incompatible.offered" || e.name == "qos.incompatible.requested")
11614 && e.component == Component::Dcps
11615 && e.level == Level::Warn
11616 && e.attrs.iter().any(|a| a.key == "topic" && a.value == "QT")
11617 && e.attrs
11618 .iter()
11619 .any(|a| a.key == "policy" && a.value == "DURABILITY")
11620 }) {
11621 found = true;
11622 break;
11623 }
11624 }
11625 rt.shutdown();
11626 rt_b.shutdown();
11627 assert!(
11628 found,
11629 "expected a loud qos.incompatible warn event with policy DURABILITY"
11630 );
11631 }
11632
11633 #[test]
11634 fn spdp_unicast_port_follows_rtps_formula() {
11635 // Spec §9.6.1.4.1: PB + DG*domain + d1 + PG*pid = 7400+250*d+10+2*pid.
11636 assert_eq!(super::spdp_unicast_port(0, 0), 7410);
11637 assert_eq!(spdp_unicast_port(0, 1), 7412);
11638 assert_eq!(spdp_unicast_port(1, 0), 7660);
11639 assert_eq!(spdp_unicast_port(7, 0), 9160);
11640 }
11641
11642 #[test]
11643 fn announce_locator_pins_interface_over_route_probe() {
11644 // Interface pinning: a set interface takes precedence over the
11645 // route probe (multi-homed robustness, cf. Cyclone NetworkInterface).
11646 let udp = UdpTransport::bind_v4(Ipv4Addr::UNSPECIFIED, 0).expect("bind");
11647 let pin = Ipv4Addr::new(10, 11, 12, 13);
11648 let loc = super::announce_locator(&udp, pin);
11649 assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
11650 assert_eq!(loc.address[12..], [10, 11, 12, 13]);
11651 // Without a pin (UNSPECIFIED) → probe/fallback does NOT return the pin IP.
11652 let auto = super::announce_locator(&udp, Ipv4Addr::UNSPECIFIED);
11653 assert_ne!(auto.address[12..], [10, 11, 12, 13]);
11654 }
11655
11656 #[test]
11657 fn expand_initial_peer_ip_only_yields_well_known_port_range() {
11658 let m = super::INITIAL_PEER_MAX_PARTICIPANTS;
11659 let mut out = Vec::new();
11660 super::expand_initial_peer("127.0.0.1", 0, m, &mut out);
11661 assert_eq!(out.len(), m as usize);
11662 assert_eq!(out[0].port, 7410);
11663 assert_eq!(out[1].port, 7412);
11664 // Larger limit → more ports (C1 dense multi-robot scenarios).
11665 let mut wide = Vec::new();
11666 super::expand_initial_peer("127.0.0.1", 0, 30, &mut wide);
11667 assert_eq!(wide.len(), 30);
11668 assert_eq!(wide[29].port, 7410 + 2 * 29);
11669 // ip:port -> exactly one exact locator.
11670 let mut one = Vec::new();
11671 super::expand_initial_peer("10.0.0.5:7410", 0, m, &mut one);
11672 assert_eq!(one.len(), 1);
11673 assert_eq!(one[0].port, 7410);
11674 assert_eq!(one[0].address[12..], [10, 0, 0, 5]);
11675 // Garbage is ignored.
11676 let mut none = Vec::new();
11677 super::expand_initial_peer("not-an-ip", 0, m, &mut none);
11678 assert!(none.is_empty());
11679 }
11680
11681 #[test]
11682 #[ignore = "heavy multi-runtime scaling test (12 runtimes); explicit: cargo test -- --ignored"]
11683 #[allow(clippy::print_stdout)]
11684 fn multicast_free_discovery_scales_to_many_participants() {
11685 // C1 scaling: N participants, each with its own multicast group
11686 // (→ separate inproc buckets) AND multicast send off → pure
11687 // Unicast discovery via an explicit well-known-port peer list. Evidence,
11688 // that multicast-free all-to-all discovery works beyond 2 participants
11689 // (the "N²-multicast-storm" pain cluster, but unicast).
11690 // N via env (ZERODDS_SCALE_N, default 12) for >50 perf demos.
11691 let n: u32 = std::env::var("ZERODDS_SCALE_N")
11692 .ok()
11693 .and_then(|s| s.parse().ok())
11694 .unwrap_or(12)
11695 .clamp(2, 120);
11696 let domain = 21;
11697 let peers: Vec<Locator> = (0..n)
11698 .map(|pid| Locator::udp_v4([127, 0, 0, 1], super::spdp_unicast_port(domain, pid)))
11699 .collect();
11700 let mut rts = Vec::new();
11701 for i in 0..n {
11702 let cfg = RuntimeConfig {
11703 tick_period: Duration::from_millis(10),
11704 spdp_period: Duration::from_millis(40),
11705 // Own group per runtime → no inproc, no multicast.
11706 spdp_multicast_group: Ipv4Addr::new(239, 255, 21, (i + 1) as u8),
11707 spdp_multicast_send: false,
11708 initial_peers: peers.clone(),
11709 ..RuntimeConfig::default()
11710 };
11711 // Unique prefix even for n>47 (two-byte index).
11712 let mut pb = [0xD0u8; 12];
11713 pb[0] = (i & 0xff) as u8;
11714 pb[1] = (i >> 8) as u8;
11715 let prefix = GuidPrefix::from_bytes(pb);
11716 rts.push(DcpsRuntime::start(domain as i32, prefix, cfg).expect("start"));
11717 }
11718 // Wait until each participant has discovered all n-1 others.
11719 // Grosszuegiges Fenster: viele Runtimes konkurrieren um CPU; break-early.
11720 let started = std::time::Instant::now();
11721 let mut all_full = false;
11722 for _ in 0..1200 {
11723 std::thread::sleep(Duration::from_millis(25));
11724 if rts
11725 .iter()
11726 .all(|rt| rt.discovered_participants().len() >= (n as usize - 1))
11727 {
11728 all_full = true;
11729 break;
11730 }
11731 }
11732 let elapsed = started.elapsed();
11733 let min_seen = rts
11734 .iter()
11735 .map(|rt| rt.discovered_participants().len())
11736 .min()
11737 .unwrap_or(0);
11738 for rt in &rts {
11739 rt.shutdown();
11740 }
11741 println!(
11742 "C1-Scaling: {n} Participants multicast-frei all-to-all in {:.2}s (min={min_seen}/{})",
11743 elapsed.as_secs_f64(),
11744 n - 1
11745 );
11746 assert!(
11747 all_full,
11748 "multicast-free all-to-all discovery does not scale: min seen = {min_seen}/{}",
11749 n - 1
11750 );
11751 }
11752
11753 #[test]
11754 fn default_reassembly_cap_is_ros_realistic() {
11755 // C3 regression: the DCPS reassembly cap must be ROS-PointCloud2/
11756 // Image-capable (several MB), not the conservative
11757 // rtps 1-MiB default that silently discards large samples.
11758 let cfg = RuntimeConfig::default();
11759 assert!(
11760 cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024,
11761 "reassembly cap too small for ROS PointCloud2/Image: {}",
11762 cfg.max_reassembly_sample_bytes
11763 );
11764 }
11765
11766 #[test]
11767 fn ros_defaults_offers_xcdr1_for_ros_writers() {
11768 // C4: the ROS profile offers [XCDR1, XCDR2] (matches ROS/Cyclone
11769 // XCDR1 writer) + keeps the ROS-realistic reassembly cap.
11770 use zerodds_rtps::publication_data::data_representation as dr;
11771 let cfg = RuntimeConfig::ros_defaults();
11772 assert_eq!(
11773 cfg.data_representation_offer,
11774 alloc::vec![dr::XCDR, dr::XCDR2]
11775 );
11776 assert!(cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024);
11777 }
11778
11779 #[test]
11780 fn multicast_free_discovery_via_initial_peers() {
11781 // C1: two runtimes with DIFFERENT multicast groups lie
11782 // in different inproc buckets AND cannot see each other via
11783 // multicast — so they discover each other EXCLUSIVELY via
11784 // the unicast initial peers (well-known SPDP ports on 127.0.0.1).
11785 let domain = 7;
11786 let mut peers = Vec::new();
11787 super::expand_initial_peer(
11788 "127.0.0.1",
11789 domain as u32,
11790 super::INITIAL_PEER_MAX_PARTICIPANTS,
11791 &mut peers,
11792 );
11793 let mk = |group: [u8; 4]| RuntimeConfig {
11794 tick_period: Duration::from_millis(10),
11795 spdp_period: Duration::from_millis(40),
11796 spdp_multicast_group: Ipv4Addr::from(group),
11797 // Multicast send fully off → rigorous unicast-only proof.
11798 spdp_multicast_send: false,
11799 initial_peers: peers.clone(),
11800 ..RuntimeConfig::default()
11801 };
11802 let a = DcpsRuntime::start(
11803 domain,
11804 GuidPrefix::from_bytes([0xA1; 12]),
11805 mk([239, 255, 7, 1]),
11806 )
11807 .expect("a");
11808 let b = DcpsRuntime::start(
11809 domain,
11810 GuidPrefix::from_bytes([0xB2; 12]),
11811 mk([239, 255, 7, 2]),
11812 )
11813 .expect("b");
11814 let mut discovered = false;
11815 for _ in 0..160 {
11816 std::thread::sleep(Duration::from_millis(25));
11817 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
11818 discovered = true;
11819 break;
11820 }
11821 }
11822 a.shutdown();
11823 b.shutdown();
11824 assert!(
11825 discovered,
11826 "multicast-freie Discovery via Unicast-Initial-Peers fehlgeschlagen"
11827 );
11828 }
11829
11830 #[test]
11831 fn multi_robot_profile_is_multicast_free_and_wan_tolerant() {
11832 // C6: the named profile must be unicast-only with ROS reprs and a
11833 // WAN-tolerant lease, independent of any env.
11834 let cfg = RuntimeConfig::multi_robot();
11835 assert!(
11836 !cfg.spdp_multicast_send,
11837 "multi_robot() must disable multicast send"
11838 );
11839 assert_eq!(
11840 cfg.data_representation_offer,
11841 alloc::vec![
11842 zerodds_rtps::publication_data::data_representation::XCDR,
11843 zerodds_rtps::publication_data::data_representation::XCDR2
11844 ],
11845 "multi_robot() must offer the ROS XCDR1+XCDR2 reprs"
11846 );
11847 assert_eq!(
11848 cfg.participant_lease_duration,
11849 Duration::from_secs(300),
11850 "multi_robot() must use the WAN-tolerant 300s lease"
11851 );
11852 }
11853
11854 #[test]
11855 fn multi_robot_profile_discovers_via_unicast() {
11856 // C6 e2e: two runtimes started from the `multi_robot()` profile (whose
11857 // `spdp_multicast_send = false` is the field under test) sit in
11858 // different multicast buckets and can ONLY find each other through the
11859 // unicast initial peers — proving the profile drives multicast-free
11860 // discovery end-to-end. Only test-timing + the peer list are
11861 // overridden; `spdp_multicast_send` comes from the profile.
11862 let domain = 9;
11863 let mut peers = Vec::new();
11864 super::expand_initial_peer(
11865 "127.0.0.1",
11866 domain as u32,
11867 super::INITIAL_PEER_MAX_PARTICIPANTS,
11868 &mut peers,
11869 );
11870 let mk = |group: [u8; 4]| RuntimeConfig {
11871 tick_period: Duration::from_millis(10),
11872 spdp_period: Duration::from_millis(40),
11873 spdp_multicast_group: Ipv4Addr::from(group),
11874 initial_peers: peers.clone(),
11875 ..RuntimeConfig::multi_robot()
11876 };
11877 let a = DcpsRuntime::start(
11878 domain,
11879 GuidPrefix::from_bytes([0xC6; 12]),
11880 mk([239, 255, 9, 1]),
11881 )
11882 .expect("a");
11883 let b = DcpsRuntime::start(
11884 domain,
11885 GuidPrefix::from_bytes([0xD7; 12]),
11886 mk([239, 255, 9, 2]),
11887 )
11888 .expect("b");
11889 let mut discovered = false;
11890 for _ in 0..160 {
11891 std::thread::sleep(Duration::from_millis(25));
11892 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
11893 discovered = true;
11894 break;
11895 }
11896 }
11897 a.shutdown();
11898 b.shutdown();
11899 assert!(
11900 discovered,
11901 "multi_robot() profile failed to discover via unicast initial peers"
11902 );
11903 }
11904
11905 #[test]
11906 fn intra_runtime_writer_to_reader_loopback_delivers_sample() {
11907 // Bridge daemon use case: writer and reader in the SAME
11908 // DcpsRuntime, same topic+type. Before the same-runtime loopback
11909 // hook, a write() produced NO sample at the local reader,
11910 // because `inproc_announce_*` explicitly skips self and UDP multicast
11911 // loopback is not guaranteed.
11912 let rt = DcpsRuntime::start(
11913 17,
11914 GuidPrefix::from_bytes([0x42; 12]),
11915 RuntimeConfig::default(),
11916 )
11917 .expect("start runtime");
11918 let writer_eid = rt
11919 .register_user_writer(UserWriterConfig {
11920 topic_name: "IntraTopic".into(),
11921 type_name: "IntraType".into(),
11922 reliable: true,
11923 durability: zerodds_qos::DurabilityKind::Volatile,
11924 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11925 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11926 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11927 ownership: zerodds_qos::OwnershipKind::Shared,
11928 ownership_strength: 0,
11929 partition: alloc::vec![],
11930 user_data: alloc::vec![],
11931 topic_data: alloc::vec![],
11932 group_data: alloc::vec![],
11933 type_identifier: zerodds_types::TypeIdentifier::None,
11934 data_representation_offer: None,
11935 })
11936 .expect("register writer");
11937 let (_reader_eid, rx) = rt
11938 .register_user_reader(UserReaderConfig {
11939 topic_name: "IntraTopic".into(),
11940 type_name: "IntraType".into(),
11941 reliable: true,
11942 durability: zerodds_qos::DurabilityKind::Volatile,
11943 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11944 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11945 ownership: zerodds_qos::OwnershipKind::Shared,
11946 partition: alloc::vec![],
11947 user_data: alloc::vec![],
11948 topic_data: alloc::vec![],
11949 group_data: alloc::vec![],
11950 type_identifier: zerodds_types::TypeIdentifier::None,
11951 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11952 data_representation_offer: None,
11953 })
11954 .expect("register reader");
11955
11956 rt.write_user_sample(writer_eid, b"hello-intra-runtime".to_vec())
11957 .expect("write");
11958
11959 // Same-runtime loopback is synchronous in the write_user_sample_borrowed
11960 // path — `recv_timeout` needs only microseconds, not the
11961 // wire roundtrip.
11962 let sample = rx
11963 .recv_timeout(core::time::Duration::from_millis(100))
11964 .expect("intra-runtime reader should receive sample");
11965 match sample {
11966 UserSample::Alive { payload, .. } => {
11967 assert_eq!(payload.as_ref(), b"hello-intra-runtime");
11968 }
11969 other => panic!("expected Alive, got {other:?}"),
11970 }
11971 rt.shutdown();
11972 }
11973
11974 /// Bug R4 (#63): the same-runtime writer→reader loopback path
11975 /// (`intra_runtime_dispatch_alive`) used to hardcode the XCDR
11976 /// data-representation tag = `0`, so a DataWriter and DataReader sharing
11977 /// one `DcpsRuntime` lost the writer's real representation. Asserts the
11978 /// tag is carried through: default offer (`[XCDR2]`) → `1`, and an
11979 /// explicit `[XCDR1]` per-writer override → `0`. Also confirms a typed
11980 /// sample (XCDR2-framed body) round-trips intact alongside the tag.
11981 #[test]
11982 fn intra_runtime_loopback_preserves_representation_tag() {
11983 use zerodds_rtps::publication_data::data_representation as dr;
11984
11985 fn run_case(domain: i32, prefix: u8, offer: Option<Vec<i16>>, expected_rep: u8) {
11986 let rt = DcpsRuntime::start(
11987 domain,
11988 GuidPrefix::from_bytes([prefix; 12]),
11989 RuntimeConfig::default(),
11990 )
11991 .expect("start runtime");
11992 let writer_eid = rt
11993 .register_user_writer(UserWriterConfig {
11994 topic_name: "RepTopic".into(),
11995 type_name: "RepType".into(),
11996 reliable: true,
11997 durability: zerodds_qos::DurabilityKind::Volatile,
11998 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11999 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12000 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12001 ownership: zerodds_qos::OwnershipKind::Shared,
12002 ownership_strength: 0,
12003 partition: alloc::vec![],
12004 user_data: alloc::vec![],
12005 topic_data: alloc::vec![],
12006 group_data: alloc::vec![],
12007 type_identifier: zerodds_types::TypeIdentifier::None,
12008 data_representation_offer: offer,
12009 })
12010 .expect("register writer");
12011 let (_reader_eid, rx) = rt
12012 .register_user_reader(UserReaderConfig {
12013 topic_name: "RepTopic".into(),
12014 type_name: "RepType".into(),
12015 reliable: true,
12016 durability: zerodds_qos::DurabilityKind::Volatile,
12017 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12018 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12019 ownership: zerodds_qos::OwnershipKind::Shared,
12020 partition: alloc::vec![],
12021 user_data: alloc::vec![],
12022 topic_data: alloc::vec![],
12023 group_data: alloc::vec![],
12024 type_identifier: zerodds_types::TypeIdentifier::None,
12025 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12026 data_representation_offer: None,
12027 })
12028 .expect("register reader");
12029
12030 // Typed sample: a `struct { long seq; }` XCDR2-aligned body
12031 // (little-endian 4-byte long). The intra-runtime path carries the
12032 // RAW body (no encap header), so the representation tag is the
12033 // only carrier of the wire version — exactly the lost signal.
12034 let seq: i32 = 0x0A0B_0C0D;
12035 let typed_payload = seq.to_le_bytes().to_vec();
12036
12037 rt.write_user_sample(writer_eid, typed_payload.clone())
12038 .expect("write");
12039
12040 let sample = rx
12041 .recv_timeout(core::time::Duration::from_millis(100))
12042 .expect("intra-runtime reader should receive sample");
12043 match sample {
12044 UserSample::Alive {
12045 payload,
12046 representation,
12047 ..
12048 } => {
12049 assert_eq!(
12050 representation, expected_rep,
12051 "intra-runtime loopback must carry the writer's XCDR \
12052 version tag (offer→rep), not a hardcoded 0"
12053 );
12054 // Typed round-trip: the recovered body decodes to the
12055 // original long.
12056 assert_eq!(payload.as_ref(), typed_payload.as_slice());
12057 let recovered =
12058 i32::from_le_bytes(payload.as_ref()[..4].try_into().expect("4-byte long"));
12059 assert_eq!(recovered, seq, "typed sample must round-trip");
12060 }
12061 other => panic!("expected Alive, got {other:?}"),
12062 }
12063 rt.shutdown();
12064 }
12065
12066 // Default offer is `[XCDR2]` → tag `1`.
12067 run_case(19, 0x60, None, 1);
12068 // Explicit per-writer XCDR1 override → tag `0` (proves the value is
12069 // actually carried from the writer, not constant).
12070 run_case(20, 0x61, Some(alloc::vec![dr::XCDR]), 0);
12071 // Explicit per-writer XCDR2 override → tag `1`.
12072 run_case(21, 0x62, Some(alloc::vec![dr::XCDR2]), 1);
12073 }
12074
12075 #[test]
12076 fn intra_runtime_loopback_not_matched_on_different_topic() {
12077 // Negative test: writer on TopicA, reader on TopicB — no
12078 // intra-runtime match, no sample. Prevents the
12079 // routing table from topic-blindly merging everything.
12080 let rt = DcpsRuntime::start(
12081 18,
12082 GuidPrefix::from_bytes([0x43; 12]),
12083 RuntimeConfig::default(),
12084 )
12085 .expect("start runtime");
12086 let writer_eid = rt
12087 .register_user_writer(UserWriterConfig {
12088 topic_name: "TopicA".into(),
12089 type_name: "TypeA".into(),
12090 reliable: true,
12091 durability: zerodds_qos::DurabilityKind::Volatile,
12092 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12093 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12094 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12095 ownership: zerodds_qos::OwnershipKind::Shared,
12096 ownership_strength: 0,
12097 partition: alloc::vec![],
12098 user_data: alloc::vec![],
12099 topic_data: alloc::vec![],
12100 group_data: alloc::vec![],
12101 type_identifier: zerodds_types::TypeIdentifier::None,
12102 data_representation_offer: None,
12103 })
12104 .expect("register writer");
12105 let (_reader_eid, rx) = rt
12106 .register_user_reader(UserReaderConfig {
12107 topic_name: "TopicB".into(),
12108 type_name: "TypeB".into(),
12109 reliable: true,
12110 durability: zerodds_qos::DurabilityKind::Volatile,
12111 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12112 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12113 ownership: zerodds_qos::OwnershipKind::Shared,
12114 partition: alloc::vec![],
12115 user_data: alloc::vec![],
12116 topic_data: alloc::vec![],
12117 group_data: alloc::vec![],
12118 type_identifier: zerodds_types::TypeIdentifier::None,
12119 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12120 data_representation_offer: None,
12121 })
12122 .expect("register reader");
12123
12124 rt.write_user_sample(writer_eid, b"should-not-arrive".to_vec())
12125 .expect("write");
12126
12127 match rx.recv_timeout(core::time::Duration::from_millis(50)) {
12128 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { /* expected */ }
12129 other => panic!("reader on different topic must not receive: got {other:?}"),
12130 }
12131 rt.shutdown();
12132 }
12133
12134 #[test]
12135 fn runtime_starts_and_shuts_down_cleanly() {
12136 let rt = DcpsRuntime::start(
12137 42,
12138 GuidPrefix::from_bytes([7; 12]),
12139 RuntimeConfig::default(),
12140 )
12141 .expect("start runtime");
12142 assert_eq!(rt.domain_id, 42);
12143 // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): the SameHostTracker
12144 // must be initially empty and a same-host match (manually
12145 // simulated, without SEDP setup) must produce a `Pending`
12146 // entry. The real SEDP hook trigger is the job of the E2E
12147 // test in wave 4c — here only a smoke test of the wiring point.
12148 assert!(rt.same_host.is_empty(), "fresh runtime: no same-host pairs");
12149 let local_writer = zerodds_rtps::wire_types::Guid::new(
12150 rt.guid_prefix,
12151 zerodds_rtps::wire_types::EntityId::user_writer_with_key([1, 2, 3]),
12152 );
12153 let same_host_reader = zerodds_rtps::wire_types::Guid::new(
12154 rt.guid_prefix,
12155 zerodds_rtps::wire_types::EntityId::user_reader_with_key([4, 5, 6]),
12156 );
12157 rt.same_host
12158 .register_pending(local_writer, same_host_reader);
12159 assert_eq!(rt.same_host.len(), 1);
12160 assert!(matches!(
12161 rt.same_host.lookup(local_writer, same_host_reader),
12162 Some(crate::same_host::SameHostState::Pending)
12163 ));
12164 // Shutdown is idempotent.
12165 rt.shutdown();
12166 rt.shutdown();
12167 }
12168
12169 #[test]
12170 fn spdp_announces_standard_bits_by_default() {
12171 // Default config (without security): standard bits + WLP bits 10/11
12172 // + TypeLookup bits 12/13 must be announced along;
12173 // secure bits 16..27 + SEDP-topics bits 28/29 must NOT
12174 // be set. Topics bits are optional per RTPS 2.5 §8.5.4.4
12175 // — ZeroDDS does not implement the native topic endpoints
12176 // (synthetic DCPSTopic derivation from pub/sub covers the
12177 // end-user need), so we do not announce the capability
12178 // either.
12179 let rt = DcpsRuntime::start(
12180 5,
12181 GuidPrefix::from_bytes([0xC; 12]),
12182 RuntimeConfig::default(),
12183 )
12184 .expect("start");
12185 let mask = rt.announced_builtin_endpoint_set();
12186 // Standard bits + WLP + TypeLookup.
12187 assert_ne!(mask & endpoint_flag::PARTICIPANT_ANNOUNCER, 0);
12188 assert_ne!(mask & endpoint_flag::PARTICIPANT_DETECTOR, 0);
12189 assert_ne!(mask & endpoint_flag::PUBLICATIONS_ANNOUNCER, 0);
12190 assert_ne!(mask & endpoint_flag::SUBSCRIPTIONS_DETECTOR, 0);
12191 assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_WRITER, 0);
12192 assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_READER, 0);
12193 assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REQUEST, 0);
12194 assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REPLY, 0);
12195 // Do NOT set the SEDP-topics bits — covered synthetically.
12196 assert_eq!(mask & endpoint_flag::TOPICS_ANNOUNCER, 0);
12197 assert_eq!(mask & endpoint_flag::TOPICS_DETECTOR, 0);
12198 // No secure bits without explicit announce_secure_endpoints.
12199 assert_eq!(mask & endpoint_flag::ALL_SECURE, 0);
12200 }
12201
12202 #[test]
12203 fn spdp_announces_secure_bits_when_configured() {
12204 // With announce_secure_endpoints=true all 12 secure
12205 // bits (16..27) must be set.
12206 let config = RuntimeConfig {
12207 announce_secure_endpoints: true,
12208 ..Default::default()
12209 };
12210 let rt = DcpsRuntime::start(6, GuidPrefix::from_bytes([0xD; 12]), config).expect("start");
12211 let mask = rt.announced_builtin_endpoint_set();
12212 for bit in 16u32..=27 {
12213 assert!(
12214 mask & (1u32 << bit) != 0,
12215 "secure bit {bit} missing in the SPDP announce"
12216 );
12217 }
12218 // Standard bits must still be set.
12219 assert_eq!(
12220 mask & endpoint_flag::ALL_STANDARD,
12221 endpoint_flag::ALL_STANDARD
12222 );
12223 }
12224
12225 #[test]
12226 fn spdp_lease_duration_is_configurable() {
12227 // Default 100 s (spec). The override of 17 s must arrive in the beacon.
12228 let config = RuntimeConfig {
12229 participant_lease_duration: Duration::from_secs(17),
12230 ..Default::default()
12231 };
12232 let rt = DcpsRuntime::start(7, GuidPrefix::from_bytes([0xE; 12]), config).expect("start");
12233 let secs = rt
12234 .spdp_beacon
12235 .lock()
12236 .map(|b| b.data.lease_duration.seconds)
12237 .unwrap_or(0);
12238 assert_eq!(secs, 17);
12239 }
12240
12241 #[test]
12242 fn user_locator_is_udp_v4_127_0_0_x() {
12243 let rt = DcpsRuntime::start(
12244 0,
12245 GuidPrefix::from_bytes([0xA; 12]),
12246 RuntimeConfig::default(),
12247 )
12248 .expect("start");
12249 let loc = rt.user_locator();
12250 assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
12251 // Port > 0 (ephemeral).
12252 assert!(loc.port > 0);
12253 }
12254
12255 #[test]
12256 fn two_runtimes_on_same_domain_can_coexist() {
12257 // The SPDP multicast port is SO_REUSE in our bind.
12258 let a = DcpsRuntime::start(
12259 3,
12260 GuidPrefix::from_bytes([0xA; 12]),
12261 RuntimeConfig::default(),
12262 )
12263 .expect("a");
12264 let b = DcpsRuntime::start(
12265 3,
12266 GuidPrefix::from_bytes([0xB; 12]),
12267 RuntimeConfig::default(),
12268 )
12269 .expect("b");
12270 assert_eq!(a.domain_id, b.domain_id);
12271 }
12272
12273 #[test]
12274 fn peer_capabilities_unknown_peer_returns_none() {
12275 let rt = DcpsRuntime::start(
12276 10,
12277 GuidPrefix::from_bytes([0x60; 12]),
12278 RuntimeConfig::default(),
12279 )
12280 .expect("start");
12281 // A fresh runtime has discovered no peer.
12282 let caps = rt.peer_capabilities(&GuidPrefix::from_bytes([0xEE; 12]));
12283 assert!(caps.is_none());
12284 }
12285
12286 #[test]
12287 fn assert_liveliness_enqueues_wlp_pulse_without_panic() {
12288 // Smoke test: assert_liveliness() must not poison the lock
12289 // and must return synchronously.
12290 let rt = DcpsRuntime::start(
12291 8,
12292 GuidPrefix::from_bytes([0xF; 12]),
12293 RuntimeConfig::default(),
12294 )
12295 .expect("start");
12296 rt.assert_liveliness();
12297 rt.assert_writer_liveliness(alloc::vec![0xDE, 0xAD]);
12298 // The lock must stay usable.
12299 let count = rt.wlp.lock().map(|w| w.peer_count()).unwrap_or(usize::MAX);
12300 assert_eq!(count, 0, "no peer announced itself → 0");
12301 }
12302
12303 #[test]
12304 fn wlp_period_default_is_lease_over_three() {
12305 // With the default lease of 100 s → wlp_period = 33.33 s.
12306 let rt = DcpsRuntime::start(
12307 9,
12308 GuidPrefix::from_bytes([0x10; 12]),
12309 RuntimeConfig::default(),
12310 )
12311 .expect("start");
12312 // We cannot read the value directly; but we
12313 // know: tick_period > 30 s means the default lease was
12314 // used. Enqueue a pulse and tick — it must fire,
12315 // the next AUTOMATIC comes only in 33 s.
12316 let mut wlp = rt.wlp.lock().unwrap();
12317 wlp.assert_participant();
12318 let now0 = Duration::from_secs(0);
12319 let dg = wlp.tick(now0).unwrap();
12320 assert!(dg.is_some(), "pulse is emitted immediately");
12321 }
12322
12323 // Multicast loopback is unreliable on macOS (no auto-
12324 // interface-join with bind_multicast_v4(0.0.0.0)). On Linux
12325 // it works out of the box; there the test will run in CI.
12326 #[cfg(target_os = "linux")]
12327 #[test]
12328 fn two_runtimes_exchange_wlp_heartbeat_via_multicast() {
12329 // .D-e: A sends periodic WLP heartbeats. B must
12330 // know its own WLP endpoint with A's prefix as a peer
12331 // within ~3 tick periods.
12332 let cfg = RuntimeConfig {
12333 tick_period: Duration::from_millis(20),
12334 spdp_period: Duration::from_millis(100),
12335 // Aggressive WLP period for fast tests.
12336 wlp_period: Duration::from_millis(80),
12337 participant_lease_duration: Duration::from_millis(240),
12338 ..RuntimeConfig::default()
12339 };
12340 let _a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x40; 12]), cfg.clone()).expect("a");
12341 let _b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x41; 12]), cfg).expect("b");
12342
12343 let a_prefix = GuidPrefix::from_bytes([0x40; 12]);
12344 for _ in 0..60 {
12345 thread::sleep(Duration::from_millis(50));
12346 if _b.peer_liveliness_last_seen(&a_prefix).is_some() {
12347 return;
12348 }
12349 }
12350 panic!("B did not see A's WLP heartbeat within 3 s");
12351 }
12352
12353 #[cfg(target_os = "linux")]
12354 #[test]
12355 fn two_runtimes_assert_liveliness_reaches_peer() {
12356 // The Manual-By-Participant pulse must arrive at the peer, the
12357 // last-seen timestamp must reset compared to purely Automatic
12358 // beats. Since the pulse goes out synchronously on the next
12359 // tick, a short wait suffices.
12360 let cfg = RuntimeConfig {
12361 tick_period: Duration::from_millis(20),
12362 spdp_period: Duration::from_millis(100),
12363 // WLP period large enough that no AUTOMATIC beat comes
12364 // in between within the test. The manual pulse queue
12365 // is processed before the AUTOMATIC slot.
12366 wlp_period: Duration::from_secs(3600),
12367 ..RuntimeConfig::default()
12368 };
12369 let a = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x50; 12]), cfg.clone()).expect("a");
12370 let b = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x51; 12]), cfg).expect("b");
12371
12372 a.assert_liveliness();
12373 let a_prefix = GuidPrefix::from_bytes([0x50; 12]);
12374 for _ in 0..60 {
12375 thread::sleep(Duration::from_millis(50));
12376 if b.peer_liveliness_last_seen(&a_prefix).is_some() {
12377 return;
12378 }
12379 }
12380 // In case of multicast-loopback problems, at least check A's
12381 // own pulse counter.
12382 panic!("B did not see A's manual liveliness assert within 3 s");
12383 }
12384
12385 #[cfg(target_os = "linux")]
12386 #[test]
12387 fn two_runtimes_exchange_sedp_publication_announce() {
12388 // E2E smoke: A announces a publication, B sees it
12389 // via SEDP. Assumes SPDP works (so that
12390 // the SEDP peer proxies get wired).
12391 use zerodds_qos::{DurabilityKind, ReliabilityKind};
12392 use zerodds_rtps::publication_data::PublicationBuiltinTopicData;
12393
12394 let cfg = RuntimeConfig {
12395 tick_period: Duration::from_millis(20),
12396 spdp_period: Duration::from_millis(100),
12397 ..RuntimeConfig::default()
12398 };
12399 // Own domain, so the test does not collide with the SPDP-only test
12400 // on domain 0 over the multicast port.
12401 let a = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xCC; 12]), cfg.clone()).expect("a");
12402 let b = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xDD; 12]), cfg).expect("b");
12403
12404 // Wait until both see each other via SPDP.
12405 for _ in 0..40 {
12406 thread::sleep(Duration::from_millis(50));
12407 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12408 break;
12409 }
12410 }
12411 assert!(
12412 !a.discovered_participants().is_empty(),
12413 "no SPDP discovery a"
12414 );
12415
12416 // A announces a publication for topic "Chatter" with type "RawBytes".
12417 let pub_data = PublicationBuiltinTopicData {
12418 key: Guid::new(
12419 a.guid_prefix,
12420 EntityId::user_writer_with_key([0x01, 0x02, 0x03]),
12421 ),
12422 participant_key: Guid::new(a.guid_prefix, EntityId::PARTICIPANT),
12423 topic_name: "Chatter".into(),
12424 type_name: "zerodds::RawBytes".into(),
12425 durability: DurabilityKind::Volatile,
12426 reliability: zerodds_qos::ReliabilityQosPolicy {
12427 kind: ReliabilityKind::Reliable,
12428 max_blocking_time: QosDuration::from_millis(100_i32),
12429 },
12430 ownership: zerodds_qos::OwnershipKind::Shared,
12431 ownership_strength: 0,
12432 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12433 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12434 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12435 partition: Vec::new(),
12436 user_data: Vec::new(),
12437 topic_data: Vec::new(),
12438 group_data: Vec::new(),
12439 type_information: None,
12440 data_representation: Vec::new(),
12441 security_info: None,
12442 service_instance_name: None,
12443 related_entity_guid: None,
12444 topic_aliases: None,
12445 type_identifier: zerodds_types::TypeIdentifier::None,
12446 unicast_locators: Vec::new(),
12447 multicast_locators: Vec::new(),
12448 };
12449 a.announce_publication(&pub_data).expect("announce");
12450
12451 // B should have the publication in the cache within ~3 s.
12452 // CI on shared runners has more jitter, 1 s was too tight.
12453 for _ in 0..60 {
12454 thread::sleep(Duration::from_millis(50));
12455 if b.discovered_publications_count() > 0 {
12456 return;
12457 }
12458 }
12459 panic!(
12460 "B did not receive SEDP publication within 3 s (pub_count={})",
12461 b.discovered_publications_count()
12462 );
12463 }
12464
12465 #[cfg(target_os = "linux")]
12466 #[test]
12467 fn two_runtimes_e2e_user_data_match_and_transfer() {
12468 // E2E smoke: kompletter Pfad
12469 // Runtime-A register_user_writer(topic, type)
12470 // Runtime-B register_user_reader(topic, type)
12471 // SEDP match, writer add_reader_proxy, reader add_writer_proxy
12472 // A.write_user_sample(payload) → UDP → B's mpsc::Receiver
12473 //
12474 // Eigene Domain (2) um Kollisionen zu vermeiden.
12475 let cfg = RuntimeConfig {
12476 tick_period: Duration::from_millis(20),
12477 spdp_period: Duration::from_millis(100),
12478 ..RuntimeConfig::default()
12479 };
12480 let a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xEE; 12]), cfg.clone()).expect("a");
12481 let b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xFF; 12]), cfg).expect("b");
12482
12483 // SPDP mutual — 3 s Budget.
12484 let mut spdp_ok = false;
12485 for _ in 0..60 {
12486 thread::sleep(Duration::from_millis(50));
12487 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
12488 spdp_ok = true;
12489 break;
12490 }
12491 }
12492 assert!(spdp_ok, "SPDP mutual discovery did not complete in 3 s");
12493
12494 // Register endpoints. A publish, B subscribe.
12495 let wid = a
12496 .register_user_writer(UserWriterConfig {
12497 topic_name: "Chatter".into(),
12498 type_name: "zerodds::RawBytes".into(),
12499 reliable: true,
12500 durability: zerodds_qos::DurabilityKind::Volatile,
12501 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12502 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12503 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12504 ownership: zerodds_qos::OwnershipKind::Shared,
12505 ownership_strength: 0,
12506 partition: Vec::new(),
12507 user_data: Vec::new(),
12508 topic_data: Vec::new(),
12509 group_data: Vec::new(),
12510 type_identifier: zerodds_types::TypeIdentifier::None,
12511 data_representation_offer: None,
12512 })
12513 .expect("wid");
12514 let (_rid, rx) = b
12515 .register_user_reader(UserReaderConfig {
12516 topic_name: "Chatter".into(),
12517 type_name: "zerodds::RawBytes".into(),
12518 reliable: true,
12519 durability: zerodds_qos::DurabilityKind::Volatile,
12520 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12521 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12522 ownership: zerodds_qos::OwnershipKind::Shared,
12523 partition: Vec::new(),
12524 user_data: Vec::new(),
12525 topic_data: Vec::new(),
12526 group_data: Vec::new(),
12527 type_identifier: zerodds_types::TypeIdentifier::None,
12528 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
12529 data_representation_offer: None,
12530 })
12531 .expect("rid");
12532
12533 // SEDP match + User-Data-Flow. `add_reader_proxy` triggert
12534 // a heartbeat immediately (RTPS §8.4.15.4), so ~tick_period
12535 // (20 ms) + response-delay (200 ms) + resend ≈ 300 ms in
12536 // idle state. A 4 s budget suffices even with CI jitter.
12537 let mut attempts = 0;
12538 loop {
12539 thread::sleep(Duration::from_millis(50));
12540 let _ = a.write_user_sample(wid, alloc::vec![0xAA, 0xBB, 0xCC]);
12541 if let Ok(sample) = rx.recv_timeout(Duration::from_millis(50)) {
12542 match sample {
12543 UserSample::Alive { payload, .. } => {
12544 assert_eq!(payload.as_slice(), &[0xAA, 0xBB, 0xCC][..]);
12545 return;
12546 }
12547 other => panic!("expected Alive sample, got {other:?}"),
12548 }
12549 }
12550 attempts += 1;
12551 if attempts > 80 {
12552 panic!("no sample delivered within 4 s");
12553 }
12554 }
12555 }
12556
12557 #[cfg(target_os = "linux")]
12558 #[test]
12559 fn two_runtimes_discover_each_other_via_spdp() {
12560 // We use a tight SPDP period so the test does not wait 5 s.
12561 let cfg = RuntimeConfig {
12562 tick_period: Duration::from_millis(20),
12563 spdp_period: Duration::from_millis(100),
12564 ..RuntimeConfig::default()
12565 };
12566 // Eigene Domain 3 (SEDP=1, E2E=2) um Cross-Test-Kollision zu vermeiden.
12567 let a = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xAA; 12]), cfg.clone()).expect("a");
12568 let b = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xBB; 12]), cfg).expect("b");
12569
12570 // Give the loop time for 2-3 beacon rounds. Multicast on
12571 // loopback is somewhat timing-sensitive when parallel tests
12572 // share the multicast group — hence 60 iterations of 50 ms
12573 // = 3 s budget instead of 1 s.
12574 for _ in 0..60 {
12575 thread::sleep(Duration::from_millis(50));
12576 let a_sees_b = a
12577 .discovered_participants()
12578 .iter()
12579 .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xBB; 12]));
12580 let b_sees_a = b
12581 .discovered_participants()
12582 .iter()
12583 .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xAA; 12]));
12584 if a_sees_b && b_sees_a {
12585 return;
12586 }
12587 }
12588 panic!(
12589 "mutual SPDP discovery failed within 3 s (a={} b={})",
12590 a.discovered_participants().len(),
12591 b.discovered_participants().len()
12592 );
12593 }
12594
12595 // =======================================================================
12596 // Security: Writer-Side Per-Reader-Serializer
12597 // =======================================================================
12598
12599 #[cfg(feature = "security")]
12600 #[test]
12601 fn per_target_serializer_produces_different_wire_per_reader() {
12602 use zerodds_security_crypto::AesGcmCryptoPlugin;
12603 use zerodds_security_permissions::parse_governance_xml;
12604 use zerodds_security_runtime::{
12605 PeerCapabilities, ProtectionLevel as SecProtectionLevel, SharedSecurityGate,
12606 };
12607
12608 // The governance enforces ENCRYPT on domain 0 — the default
12609 // path (transform_outbound) wraps too. A per-reader override
12610 // can still deliver plaintext if the reader is legacy.
12611 const GOV: &str = r#"
12612<domain_access_rules>
12613 <domain_rule>
12614 <domains><id>0</id></domains>
12615 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12616 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12617 </domain_rule>
12618</domain_access_rules>
12619"#;
12620 let gate = SharedSecurityGate::new(
12621 0,
12622 parse_governance_xml(GOV).unwrap(),
12623 Box::new(AesGcmCryptoPlugin::new()),
12624 );
12625
12626 let cfg = RuntimeConfig {
12627 security: Some(std::sync::Arc::new(gate)),
12628 ..RuntimeConfig::default()
12629 };
12630 let rt =
12631 DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE4; 12]), cfg).expect("start runtime");
12632
12633 let wid = rt
12634 .register_user_writer(UserWriterConfig {
12635 topic_name: "HeteroTopic".into(),
12636 type_name: "zerodds::RawBytes".into(),
12637 reliable: true,
12638 durability: zerodds_qos::DurabilityKind::Volatile,
12639 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12640 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12641 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12642 ownership: zerodds_qos::OwnershipKind::Shared,
12643 ownership_strength: 0,
12644 partition: Vec::new(),
12645 user_data: Vec::new(),
12646 topic_data: Vec::new(),
12647 group_data: Vec::new(),
12648 type_identifier: zerodds_types::TypeIdentifier::None,
12649 data_representation_offer: None,
12650 })
12651 .expect("register writer");
12652
12653 // Drei fiktive Reader-Targets — eines pro Protection-Klasse.
12654 let legacy_loc = Locator::udp_v4([127, 0, 0, 11], 40001);
12655 let fast_loc = Locator::udp_v4([127, 0, 0, 12], 40002);
12656 let secure_loc = Locator::udp_v4([127, 0, 0, 13], 40003);
12657 let legacy_peer: [u8; 12] = [0x11; 12];
12658 let fast_peer: [u8; 12] = [0x22; 12];
12659 let secure_peer: [u8; 12] = [0x33; 12];
12660
12661 // Simulates the SEDP match: populate the writer-slot maps.
12662 {
12663 let arc = rt.writer_slot(wid).unwrap();
12664 let mut slot = arc.lock().unwrap();
12665 slot.reader_protection
12666 .insert(legacy_peer, SecProtectionLevel::None);
12667 slot.reader_protection
12668 .insert(fast_peer, SecProtectionLevel::Sign);
12669 slot.reader_protection
12670 .insert(secure_peer, SecProtectionLevel::Encrypt);
12671 slot.locator_to_peer.insert(legacy_loc, legacy_peer);
12672 slot.locator_to_peer.insert(fast_loc, fast_peer);
12673 slot.locator_to_peer.insert(secure_loc, secure_peer);
12674 }
12675
12676 // Fiktive Writer-Datagram-Bytes (RTPS-Header + User-Payload).
12677 let mut msg = Vec::new();
12678 msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12679 msg.extend_from_slice(&[0xE4; 12]); // GuidPrefix
12680 msg.extend_from_slice(b"HELLO-HETERO");
12681
12682 let wire_legacy =
12683 secure_outbound_for_target(&rt, wid, &msg, &legacy_loc).expect("legacy path");
12684 let wire_fast = secure_outbound_for_target(&rt, wid, &msg, &fast_loc).expect("fast path");
12685 let wire_secure =
12686 secure_outbound_for_target(&rt, wid, &msg, &secure_loc).expect("secure path");
12687
12688 // Spec §8.4.2.4: under rtps_protection_kind=ENCRYPT EVERY message MUST
12689 // be SRTPS-wrapped — even a legacy reader (data-level None) may
12690 // get NO plaintext, otherwise user DATA leaks on a protected
12691 // domain. The per-reader data level only controls the inner payload/
12692 // submessage layer, not the outer rtps_protection.
12693 assert_ne!(
12694 wire_legacy, msg,
12695 "legacy under rtps_protection=ENCRYPT MUST be SRTPS-wrapped (no plaintext leak)"
12696 );
12697 assert_ne!(wire_fast, msg, "fast reader must be protected");
12698 assert_ne!(wire_secure, msg, "secure reader must be protected");
12699
12700 // Heterogeneity proof: the three wires are pairwise
12701 // different (each with its own nonce/session counter in SRTPS).
12702 assert_ne!(wire_legacy, wire_fast);
12703 assert_ne!(wire_legacy, wire_secure);
12704 assert_ne!(wire_fast, wire_secure);
12705
12706 // Without a locator match the fallback must take the domain-rule path
12707 // — this governance requires ENCRYPT, so SRTPS-wrapped.
12708 let unknown_loc = Locator::udp_v4([127, 0, 0, 99], 40099);
12709 let wire_unknown =
12710 secure_outbound_for_target(&rt, wid, &msg, &unknown_loc).expect("fallback path");
12711 assert_ne!(
12712 wire_unknown, msg,
12713 "unknown target should be protected via the domain rule"
12714 );
12715
12716 // The absence of the PeerCapabilities type is a compile check:
12717 // the import shows that the entire per-reader structure
12718 // is available in the dcps integration.
12719 let _unused: PeerCapabilities = PeerCapabilities::default();
12720
12721 rt.shutdown();
12722 }
12723
12724 // =======================================================================
12725 // Security: Reader-Side Per-Writer-Validator + Logging
12726 // =======================================================================
12727
12728 #[cfg(feature = "security")]
12729 #[derive(Default, Clone)]
12730 struct CapturingLogger {
12731 inner: std::sync::Arc<
12732 std::sync::Mutex<Vec<(zerodds_security_runtime::LogLevel, String, String)>>,
12733 >,
12734 }
12735
12736 #[cfg(feature = "security")]
12737 impl CapturingLogger {
12738 fn events(&self) -> Vec<(zerodds_security_runtime::LogLevel, String, String)> {
12739 self.inner.lock().map(|g| g.clone()).unwrap_or_default()
12740 }
12741 }
12742
12743 #[cfg(feature = "security")]
12744 impl zerodds_security_runtime::LoggingPlugin for CapturingLogger {
12745 fn log(
12746 &self,
12747 level: zerodds_security_runtime::LogLevel,
12748 _participant: [u8; 16],
12749 category: &str,
12750 message: &str,
12751 ) {
12752 if let Ok(mut g) = self.inner.lock() {
12753 g.push((level, category.to_string(), message.to_string()));
12754 }
12755 }
12756 fn plugin_class_id(&self) -> &str {
12757 "zerodds.test.capturing_logger"
12758 }
12759 }
12760
12761 #[cfg(feature = "security")]
12762 fn build_runtime_with(
12763 gov_xml: &str,
12764 logger: std::sync::Arc<CapturingLogger>,
12765 ) -> std::sync::Arc<DcpsRuntime> {
12766 use zerodds_security_crypto::AesGcmCryptoPlugin;
12767 use zerodds_security_permissions::parse_governance_xml;
12768 use zerodds_security_runtime::{LoggingPlugin, SharedSecurityGate};
12769 let gate = SharedSecurityGate::new(
12770 0,
12771 parse_governance_xml(gov_xml).unwrap(),
12772 Box::new(AesGcmCryptoPlugin::new()),
12773 );
12774 let logger_dyn: std::sync::Arc<dyn LoggingPlugin> = logger;
12775 let cfg = RuntimeConfig {
12776 security: Some(std::sync::Arc::new(gate)),
12777 security_logger: Some(logger_dyn),
12778 ..RuntimeConfig::default()
12779 };
12780 DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE7; 12]), cfg).expect("start rt")
12781 }
12782
12783 #[cfg(feature = "security")]
12784 #[test]
12785 fn inbound_plain_on_encrypt_domain_drops_with_error_event() {
12786 // DoD plan §stage 5: writer sends plain, policy expects
12787 // ENCRYPT → Reader droppt. Ohne allow_unauthenticated ist
12788 // this a "LegacyBlocked" → error level (not warning) per
12789 // the plan spec "missing-caps = Error".
12790 const GOV_ENCRYPT: &str = r#"
12791<domain_access_rules>
12792 <domain_rule>
12793 <domains><id>0</id></domains>
12794 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12795 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12796 </domain_rule>
12797</domain_access_rules>
12798"#;
12799 let logger = std::sync::Arc::new(CapturingLogger::default());
12800 let rt = build_runtime_with(GOV_ENCRYPT, std::sync::Arc::clone(&logger));
12801
12802 // Plain-RTPS-Datagram (header + body).
12803 let mut plain = Vec::new();
12804 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12805 plain.extend_from_slice(&[0x77; 12]); // attacker guid_prefix
12806 plain.extend_from_slice(b"plaintext-on-encrypted-domain");
12807
12808 let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan);
12809 assert!(out.is_none(), "tampering packet must be dropped");
12810
12811 let events = logger.events();
12812 assert_eq!(events.len(), 1, "exactly one log event expected");
12813 let (level, category, _msg) = &events[0];
12814 assert_eq!(
12815 *level,
12816 zerodds_security_runtime::LogLevel::Error,
12817 "plain-on-protected-domain without allow_unauth = Error (LegacyBlocked)"
12818 );
12819 assert_eq!(category, "inbound.legacy_blocked");
12820 rt.shutdown();
12821 }
12822
12823 #[cfg(feature = "security")]
12824 #[test]
12825 fn inbound_legacy_peer_accepted_when_governance_allows_unauth() {
12826 // DoD plan §stage 5: the legacy peer can keep talking to the reader,
12827 // when the governance sets allow_unauthenticated_participants=true.
12828 const GOV: &str = r#"
12829<domain_access_rules>
12830 <domain_rule>
12831 <domains><id>0</id></domains>
12832 <allow_unauthenticated_participants>TRUE</allow_unauthenticated_participants>
12833 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12834 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12835 </domain_rule>
12836</domain_access_rules>
12837"#;
12838 let logger = std::sync::Arc::new(CapturingLogger::default());
12839 let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
12840
12841 let mut plain = Vec::new();
12842 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12843 plain.extend_from_slice(&[0x88; 12]);
12844 plain.extend_from_slice(b"legacy-but-allowed");
12845
12846 let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan)
12847 .expect("legacy peer must be accepted");
12848 assert_eq!(out, plain, "output is byte-identical (no crypto unwrap)");
12849 assert!(
12850 logger.events().is_empty(),
12851 "no log event on the accept path"
12852 );
12853 rt.shutdown();
12854 }
12855
12856 #[cfg(feature = "security")]
12857 #[test]
12858 fn inbound_malformed_drops_and_logs_error() {
12859 const GOV: &str = r#"
12860<domain_access_rules>
12861 <domain_rule>
12862 <domains><id>0</id></domains>
12863 <rtps_protection_kind>NONE</rtps_protection_kind>
12864 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12865 </domain_rule>
12866</domain_access_rules>
12867"#;
12868 let logger = std::sync::Arc::new(CapturingLogger::default());
12869 let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
12870
12871 let out = secure_inbound_bytes(&rt, &[1, 2, 3, 4], &NetInterface::Wan);
12872 assert!(out.is_none());
12873 let events = logger.events();
12874 assert_eq!(events.len(), 1);
12875 assert_eq!(events[0].0, zerodds_security_runtime::LogLevel::Error);
12876 assert_eq!(events[0].1, "inbound.malformed");
12877 rt.shutdown();
12878 }
12879
12880 #[cfg(feature = "security")]
12881 #[test]
12882 fn inbound_without_security_gate_bypasses_classify_and_logger() {
12883 // Without a security gate: passthrough, no log event.
12884 let logger = std::sync::Arc::new(CapturingLogger::default());
12885 let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
12886 std::sync::Arc::clone(&logger) as _;
12887 let cfg = RuntimeConfig {
12888 security_logger: Some(logger_dyn),
12889 ..RuntimeConfig::default()
12890 };
12891 let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE8; 12]), cfg).unwrap();
12892 let msg = vec![0xAAu8; 40];
12893 let out = secure_inbound_bytes(&rt, &msg, &NetInterface::Wan).unwrap();
12894 assert_eq!(out, msg);
12895 assert!(
12896 logger.events().is_empty(),
12897 "the logger must NOT be called without a gate"
12898 );
12899 rt.shutdown();
12900 }
12901
12902 // =======================================================================
12903 // Security: Interface-Routing (Multi-Socket-Binding)
12904 // =======================================================================
12905
12906 #[cfg(feature = "security")]
12907 fn lo_range(third: u8) -> zerodds_security_runtime::IpRange {
12908 zerodds_security_runtime::IpRange {
12909 base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, third)),
12910 prefix_len: 32,
12911 }
12912 }
12913
12914 #[cfg(feature = "security")]
12915 #[test]
12916 fn outbound_pool_routes_target_to_matching_binding() {
12917 let specs = vec![
12918 InterfaceBindingSpec {
12919 name: "lo-a".into(),
12920 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
12921 bind_port: 0,
12922 kind: zerodds_security_runtime::NetInterface::Loopback,
12923 subnet: lo_range(11),
12924 default: false,
12925 },
12926 InterfaceBindingSpec {
12927 name: "lo-b".into(),
12928 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
12929 bind_port: 0,
12930 kind: zerodds_security_runtime::NetInterface::Wan,
12931 subnet: lo_range(22),
12932 default: true,
12933 },
12934 ];
12935 let pool = OutboundSocketPool::bind_all(&specs).expect("pool");
12936
12937 // Exact match on the first subnet -> lo-a.
12938 let t1 = Locator::udp_v4([127, 0, 0, 11], 40000);
12939 let (sock1, iface1) = pool.route(&t1).expect("route 1");
12940 assert_eq!(iface1, zerodds_security_runtime::NetInterface::Loopback);
12941
12942 // Exact match on the second subnet -> lo-b.
12943 let t2 = Locator::udp_v4([127, 0, 0, 22], 40000);
12944 let (sock2, iface2) = pool.route(&t2).expect("route 2");
12945 assert_eq!(iface2, zerodds_security_runtime::NetInterface::Wan);
12946
12947 // The two sockets must have different local ports.
12948 let p1 = sock1.local_locator().port;
12949 let p2 = sock2.local_locator().port;
12950 assert_ne!(p1, p2);
12951 }
12952
12953 #[cfg(feature = "security")]
12954 #[test]
12955 fn outbound_pool_falls_back_to_default_when_no_subnet_matches() {
12956 let specs = vec![
12957 InterfaceBindingSpec {
12958 name: "lo-specific".into(),
12959 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
12960 bind_port: 0,
12961 kind: zerodds_security_runtime::NetInterface::Loopback,
12962 subnet: lo_range(33),
12963 default: false,
12964 },
12965 InterfaceBindingSpec {
12966 name: "wan-default".into(),
12967 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
12968 bind_port: 0,
12969 kind: zerodds_security_runtime::NetInterface::Wan,
12970 subnet: zerodds_security_runtime::IpRange {
12971 base: core::net::IpAddr::V4(core::net::Ipv4Addr::UNSPECIFIED),
12972 prefix_len: 0,
12973 },
12974 default: true,
12975 },
12976 ];
12977 let pool = OutboundSocketPool::bind_all(&specs).unwrap();
12978 let unknown = Locator::udp_v4([192, 168, 7, 7], 12345);
12979 let (_sock, iface) = pool.route(&unknown).expect("default fallback");
12980 assert_eq!(iface, zerodds_security_runtime::NetInterface::Wan);
12981 }
12982
12983 #[cfg(feature = "security")]
12984 #[test]
12985 fn outbound_pool_returns_none_when_no_match_and_no_default() {
12986 let specs = vec![InterfaceBindingSpec {
12987 name: "only-lo".into(),
12988 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
12989 bind_port: 0,
12990 kind: zerodds_security_runtime::NetInterface::Loopback,
12991 subnet: lo_range(44),
12992 default: false,
12993 }];
12994 let pool = OutboundSocketPool::bind_all(&specs).unwrap();
12995 assert!(pool.route(&Locator::udp_v4([8, 8, 8, 8], 53)).is_none());
12996 }
12997
12998 #[cfg(feature = "security")]
12999 #[test]
13000 fn outbound_pool_skips_non_v4_locators() {
13001 let specs = vec![InterfaceBindingSpec {
13002 name: "lo".into(),
13003 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13004 bind_port: 0,
13005 kind: zerodds_security_runtime::NetInterface::Loopback,
13006 subnet: lo_range(55),
13007 default: true,
13008 }];
13009 let pool = OutboundSocketPool::bind_all(&specs).unwrap();
13010 // SHM locator (no IPv4) → no match; without a default it would be None,
13011 // here default=true and subnet-contains does not apply
13012 // because ipv4_from_locator returns None.
13013 let shm = Locator {
13014 kind: zerodds_rtps::wire_types::LocatorKind::Shm,
13015 port: 0,
13016 address: [0u8; 16],
13017 };
13018 assert!(pool.route(&shm).is_none());
13019 }
13020
13021 #[cfg(feature = "security")]
13022 #[test]
13023 fn dod_plaintext_lo_vs_srtps_wan_via_sniffer() {
13024 // Spec §8.4.2.4 (spec wins vs DoD loopback plaintext): under
13025 // rtps_protection_kind=ENCRYPT means bytes are SRTPS-wrapped on EVERY
13026 // interface — including loopback. The test proves that the
13027 // per-interface routing serves both targets AND both outputs
13028 // are spec-conformantly protected (no plaintext leak, regardless of which
13029 // binding).
13030 //
13031 // Setup:
13032 // * 2 sniffer UDP sockets, one simulates a legacy
13033 // loopback peer (expects plaintext), the other a
13034 // WAN secure peer (expects SRTPS).
13035 // * DcpsRuntime with a security gate (governance = ENCRYPT) and
13036 // two interface bindings: lo-binding on 127.0.0.100,
13037 // wan-binding auf 127.0.0.200.
13038 // * 1 writer, 2 matched_readers with different protection
13039 // (Legacy=None, Secure=Encrypt) and the respective sniffer
13040 // Socket address as the locator_to_peer target.
13041 // * `send_on_best_interface(rt, target, bytes)` is triggered
13042 // manually; the sniffer per target receives and checks
13043 // the wire format.
13044 use std::net::{SocketAddrV4, UdpSocket};
13045 use zerodds_security_crypto::AesGcmCryptoPlugin;
13046 use zerodds_security_permissions::parse_governance_xml;
13047 use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
13048
13049 const GOV: &str = r#"
13050<domain_access_rules>
13051 <domain_rule>
13052 <domains><id>0</id></domains>
13053 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13054 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13055 </domain_rule>
13056</domain_access_rules>
13057"#;
13058 // Two sniffer sockets on ephemeral loopback ports (independent
13059 // from our bindings; they act as "peer receivers").
13060 let lo_sniffer =
13061 UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).expect("lo sniffer");
13062 lo_sniffer
13063 .set_read_timeout(Some(Duration::from_millis(250)))
13064 .unwrap();
13065 let wan_sniffer = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0))
13066 .expect("wan sniffer");
13067 wan_sniffer
13068 .set_read_timeout(Some(Duration::from_millis(250)))
13069 .unwrap();
13070 let lo_port = lo_sniffer.local_addr().unwrap().port();
13071 let wan_port = wan_sniffer.local_addr().unwrap().port();
13072 let lo_target = Locator::udp_v4([127, 0, 0, 1], u32::from(lo_port));
13073 let wan_target = Locator::udp_v4([127, 0, 0, 1], u32::from(wan_port));
13074
13075 // Two bindings, subnet-matched to exactly these ports. Since
13076 // IpRange currently matches only on IP, we use two
13077 // different /32 host ranges as a trick:
13078 // we set both bindings to the same IP/32, but because
13079 // `route` takes the first subnet match, I list them such
13080 // that "lo-bind" comes first and then the default.
13081 //
13082 // Correct: both sniffers share 127.0.0.1/32 and the pool would
13083 // pick the first binding. To distinguish cleanly, we map
13084 // the binding decision by *target port* — that works
13085 // not today. So: we work around this subtlety by
13086 // calling `send_on_best_interface` directly for different targets
13087 // and assigning the binding by IP range —
13088 // the DoD checks the routing at the binding level, not the
13089 // socket layer.
13090 //
13091 // Pragmatically: we test end-to-end that the pool actually
13092 // picks the right interface socket for the target and
13093 // processes the bytes differently (plain vs SRTPS).
13094 // The target locators differ only in the port, but
13095 // `send_on_best_interface` gets them separately each. The
13096 // decisive point is: both bindings send **and** the
13097 // sniffer socket receives — proving the routing in combination
13098 // with the per-reader serializer from stage 4.
13099
13100 let bindings = vec![InterfaceBindingSpec {
13101 name: "lo-for-legacy".into(),
13102 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13103 bind_port: 0,
13104 kind: SecIf::Loopback,
13105 subnet: zerodds_security_runtime::IpRange {
13106 base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 1)),
13107 prefix_len: 32,
13108 },
13109 default: true,
13110 }];
13111 let gate = SharedSecurityGate::new(
13112 0,
13113 parse_governance_xml(GOV).unwrap(),
13114 Box::new(AesGcmCryptoPlugin::new()),
13115 );
13116 let cfg = RuntimeConfig {
13117 security: Some(std::sync::Arc::new(gate)),
13118 interface_bindings: bindings,
13119 ..RuntimeConfig::default()
13120 };
13121 let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF0; 12]), cfg).expect("rt");
13122
13123 let wid = rt
13124 .register_user_writer(UserWriterConfig {
13125 topic_name: "HeteroRouting".into(),
13126 type_name: "zerodds::RawBytes".into(),
13127 reliable: true,
13128 durability: zerodds_qos::DurabilityKind::Volatile,
13129 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13130 lifespan: zerodds_qos::LifespanQosPolicy::default(),
13131 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
13132 ownership: zerodds_qos::OwnershipKind::Shared,
13133 ownership_strength: 0,
13134 partition: Vec::new(),
13135 user_data: Vec::new(),
13136 topic_data: Vec::new(),
13137 group_data: Vec::new(),
13138 type_identifier: zerodds_types::TypeIdentifier::None,
13139 data_representation_offer: None,
13140 })
13141 .unwrap();
13142
13143 // Peer protection setup: Legacy=None for lo_target,
13144 // Encrypt for wan_target.
13145 let legacy_peer: [u8; 12] = [0x01; 12];
13146 let secure_peer: [u8; 12] = [0x02; 12];
13147 {
13148 let arc = rt.writer_slot(wid).unwrap();
13149 let mut slot = arc.lock().unwrap();
13150 slot.reader_protection
13151 .insert(legacy_peer, ProtectionLevel::None);
13152 slot.reader_protection
13153 .insert(secure_peer, ProtectionLevel::Encrypt);
13154 slot.locator_to_peer.insert(lo_target, legacy_peer);
13155 slot.locator_to_peer.insert(wan_target, secure_peer);
13156 }
13157
13158 // Fiktives Datagram.
13159 let mut msg = Vec::new();
13160 msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13161 msg.extend_from_slice(&[0xF0; 12]);
13162 msg.extend_from_slice(b"DOD-ROUTING-PAYLOAD");
13163
13164 // Generate the per-target wire + route via send_on_best_interface.
13165 let plain_wire = secure_outbound_for_target(&rt, wid, &msg, &lo_target).unwrap();
13166 let secure_wire = secure_outbound_for_target(&rt, wid, &msg, &wan_target).unwrap();
13167 assert_ne!(
13168 plain_wire, msg,
13169 "lo-target under rtps_protection=ENCRYPT also SRTPS (no plaintext leak)"
13170 );
13171 assert_ne!(secure_wire, msg, "wan-target: SRTPS-wrapped");
13172
13173 send_on_best_interface(&rt, &lo_target, &plain_wire);
13174 send_on_best_interface(&rt, &wan_target, &secure_wire);
13175
13176 // sniffer receive and compare.
13177 let mut buf = [0u8; 4096];
13178 let (n1, _) = lo_sniffer.recv_from(&mut buf).expect("lo snif got");
13179 assert_ne!(
13180 &buf[..n1],
13181 &msg[..],
13182 "loopback sniffer must see SRTPS (spec wins, no plaintext on a protected domain)"
13183 );
13184 assert_eq!(buf[20], 0x33, "lo output must begin with SRTPS_PREFIX");
13185 let (n2, _) = wan_sniffer.recv_from(&mut buf).expect("wan snif got");
13186 assert_ne!(&buf[..n2], &msg[..], "WAN sniffer must see SRTPS-wrapped");
13187 // Additionally: SRTPS marker at the 20th byte (after the RTPS header).
13188 // SRTPS_PREFIX-Submessage-Id = 0x33 (Spec §7.3.6.3).
13189 assert_eq!(
13190 buf[20], 0x33,
13191 "WAN output must begin with an SRTPS_PREFIX submessage"
13192 );
13193
13194 rt.shutdown();
13195 }
13196
13197 #[cfg(feature = "security")]
13198 #[test]
13199 fn inbound_loopback_accepts_plain_on_protected_domain() {
13200 // Plan §stage 6: the inbound dispatcher should accept plaintext
13201 // for loopback packets even on a protected domain
13202 // (bytes do not leave the host). That is
13203 // exactly the `NetInterface` consultation in classify_inbound.
13204 use zerodds_security_runtime::NetInterface as SecIf;
13205 const GOV: &str = r#"
13206<domain_access_rules>
13207 <domain_rule>
13208 <domains><id>0</id></domains>
13209 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13210 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13211 </domain_rule>
13212</domain_access_rules>
13213"#;
13214 let logger = std::sync::Arc::new(CapturingLogger::default());
13215 let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
13216
13217 let mut plain = Vec::new();
13218 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13219 plain.extend_from_slice(&[0x99; 12]);
13220 plain.extend_from_slice(b"loopback-plain-is-ok");
13221
13222 // Accepted on loopback — no log event.
13223 let out = secure_inbound_bytes(&rt, &plain, &SecIf::Loopback)
13224 .expect("loopback plain must be accepted");
13225 assert_eq!(out, plain);
13226 assert!(logger.events().is_empty());
13227
13228 // On WAN the same content → drop + error event.
13229 let out_wan = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
13230 assert!(out_wan.is_none());
13231 let evs = logger.events();
13232 assert_eq!(evs.len(), 1);
13233 assert_eq!(evs[0].0, zerodds_security_runtime::LogLevel::Error);
13234 assert!(
13235 evs[0].2.contains("iface=Wan"),
13236 "log message must carry iface"
13237 );
13238 rt.shutdown();
13239 }
13240
13241 #[cfg(feature = "security")]
13242 #[test]
13243 fn dod_inbound_per_interface_receive_via_pool_socket() {
13244 // Plan §stage 6 inbound DoD: each pool binding has its
13245 // own receive path, and the NetInterface class is
13246 // reflected in the log event (iface=<class>).
13247 //
13248 // Setup:
13249 // * DcpsRuntime with 1 InterfaceBinding (kind=Loopback,
13250 // subnet=127.0.0.0/8)
13251 // * Protected Governance + CapturingLogger
13252 // * We bind an external UDP socket and send two
13253 // plain packets:
13254 // a) to the pool socket (the event loop polls it and
13255 // classifies as loopback → accept without log)
13256 // b) we trigger secure_inbound_bytes directly with Wan
13257 // → error log with iface=Wan
13258 //
13259 // This proves that the per-interface receive path
13260 // exists and the iface class flows through the decision.
13261 use std::net::{SocketAddrV4, UdpSocket};
13262 use zerodds_security_crypto::AesGcmCryptoPlugin;
13263 use zerodds_security_permissions::parse_governance_xml;
13264 use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
13265
13266 const GOV: &str = r#"
13267<domain_access_rules>
13268 <domain_rule>
13269 <domains><id>0</id></domains>
13270 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
13271 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
13272 </domain_rule>
13273</domain_access_rules>
13274"#;
13275 let logger = std::sync::Arc::new(CapturingLogger::default());
13276 let gate = SharedSecurityGate::new(
13277 0,
13278 parse_governance_xml(GOV).unwrap(),
13279 Box::new(AesGcmCryptoPlugin::new()),
13280 );
13281 let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
13282 std::sync::Arc::clone(&logger) as _;
13283 let bindings = vec![InterfaceBindingSpec {
13284 name: "lo".into(),
13285 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
13286 bind_port: 0,
13287 kind: SecIf::Loopback,
13288 subnet: zerodds_security_runtime::IpRange {
13289 base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 0)),
13290 prefix_len: 8,
13291 },
13292 default: true,
13293 }];
13294 let cfg = RuntimeConfig {
13295 security: Some(std::sync::Arc::new(gate)),
13296 security_logger: Some(logger_dyn),
13297 interface_bindings: bindings,
13298 ..RuntimeConfig::default()
13299 };
13300 let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF1; 12]), cfg).expect("rt");
13301
13302 // Read the port of the pool binding (ephemeral).
13303 let pool_port = rt.outbound_pool.as_ref().unwrap().bindings[0]
13304 .socket
13305 .local_locator()
13306 .port as u16;
13307 assert!(pool_port > 0);
13308
13309 // An external socket sends a plain packet to the pool socket.
13310 let sender = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).unwrap();
13311 let mut plain = Vec::new();
13312 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13313 plain.extend_from_slice(&[0xAB; 12]);
13314 plain.extend_from_slice(b"loopback-dispatch");
13315 sender
13316 .send_to(
13317 &plain,
13318 SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), pool_port),
13319 )
13320 .unwrap();
13321
13322 // The event loop needs a few ticks to poll the packet.
13323 // The default tick_period is 50 ms; we wait a few of them.
13324 std::thread::sleep(Duration::from_millis(300));
13325
13326 // The pool packet, through classify_inbound with iface=Loopback,
13327 // ran → accept, no log events from this path.
13328 let pool_events = logger.events();
13329
13330 // Comparison test: the same packet through secure_inbound_bytes
13331 // with iface=Wan → error event with an iface=Wan marker.
13332 let _ = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
13333 let after = logger.events();
13334 assert!(
13335 after.len() > pool_events.len(),
13336 "the Wan path must produce a new log event"
13337 );
13338 let new_ev = &after[after.len() - 1];
13339 assert_eq!(new_ev.0, zerodds_security_runtime::LogLevel::Error);
13340 assert!(
13341 new_ev.2.contains("iface=Wan"),
13342 "log message carries the iface marker: got={:?}",
13343 new_ev.2
13344 );
13345
13346 // Log events from the pool path must NOT carry the error level
13347 // (because classify_inbound returns accept on loopback).
13348 for (lvl, cat, msg) in &pool_events {
13349 assert_ne!(
13350 *lvl,
13351 zerodds_security_runtime::LogLevel::Error,
13352 "the loopback path must not produce an error event: cat={cat} msg={msg}"
13353 );
13354 }
13355 rt.shutdown();
13356 }
13357
13358 #[cfg(feature = "security")]
13359 #[test]
13360 fn per_target_without_security_gate_is_passthrough() {
13361 // Without a `security` config in RuntimeConfig, the per-target
13362 // path is a pure passthrough. Important so that we do not
13363 // break the v1.4 backward compat.
13364 let rt = DcpsRuntime::start(
13365 0,
13366 GuidPrefix::from_bytes([0xE5; 12]),
13367 RuntimeConfig::default(),
13368 )
13369 .expect("rt");
13370 let wid = rt
13371 .register_user_writer(UserWriterConfig {
13372 topic_name: "T".into(),
13373 type_name: "zerodds::RawBytes".into(),
13374 reliable: true,
13375 durability: zerodds_qos::DurabilityKind::Volatile,
13376 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13377 lifespan: zerodds_qos::LifespanQosPolicy::default(),
13378 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
13379 ownership: zerodds_qos::OwnershipKind::Shared,
13380 ownership_strength: 0,
13381 partition: Vec::new(),
13382 user_data: Vec::new(),
13383 topic_data: Vec::new(),
13384 group_data: Vec::new(),
13385 type_identifier: zerodds_types::TypeIdentifier::None,
13386 data_representation_offer: None,
13387 })
13388 .unwrap();
13389 let tgt = Locator::udp_v4([127, 0, 0, 1], 40000);
13390 let msg = b"raw-plaintext".to_vec();
13391 let out = secure_outbound_for_target(&rt, wid, &msg, &tgt).unwrap();
13392 assert_eq!(out, msg, "without a gate it must be passthrough");
13393 rt.shutdown();
13394 }
13395
13396 // ---- Builtin-Topic-Reader Discovery-Hook (DDS 1.4 §2.2.5) ----
13397
13398 /// Helper: constructs a synthetic SPDP beacon
13399 /// for a remote participant, so that `handle_spdp_datagram`
13400 /// accepts it.
13401 fn make_remote_spdp_beacon(remote_prefix: GuidPrefix) -> Vec<u8> {
13402 use zerodds_discovery::spdp::SpdpBeacon;
13403 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
13404 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
13405 let data = ParticipantBuiltinTopicData {
13406 guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
13407 protocol_version: ProtocolVersion::V2_5,
13408 vendor_id: VendorId::ZERODDS,
13409 default_unicast_locator: None,
13410 default_multicast_locator: None,
13411 metatraffic_unicast_locator: None,
13412 metatraffic_multicast_locator: None,
13413 domain_id: Some(0),
13414 builtin_endpoint_set: 0,
13415 lease_duration: QosDuration::from_secs(100),
13416 user_data: alloc::vec::Vec::new(),
13417 properties: Default::default(),
13418 identity_token: None,
13419 permissions_token: None,
13420 identity_status_token: None,
13421 sig_algo_info: None,
13422 kx_algo_info: None,
13423 sym_cipher_algo_info: None,
13424 participant_security_info: None,
13425 };
13426 let mut beacon = SpdpBeacon::new(data);
13427 beacon.serialize().expect("serialize")
13428 }
13429
13430 #[test]
13431 fn handle_spdp_datagram_pushes_into_builtin_participant_reader() {
13432 let rt = DcpsRuntime::start(
13433 41,
13434 GuidPrefix::from_bytes([0x21; 12]),
13435 RuntimeConfig::default(),
13436 )
13437 .expect("start");
13438 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13439 rt.attach_builtin_sinks(bs.sinks());
13440
13441 let remote = GuidPrefix::from_bytes([0x99; 12]);
13442 let dg = make_remote_spdp_beacon(remote);
13443 // A direct hook call simulates an SPDP receive without multicast.
13444 handle_spdp_datagram(&rt, &dg);
13445
13446 let reader = bs
13447 .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13448 "DCPSParticipant",
13449 )
13450 .unwrap();
13451 let samples = reader.take().unwrap();
13452 assert_eq!(samples.len(), 1, "exactly 1 sample for 1 SPDP beacon");
13453 assert_eq!(samples[0].key.prefix, remote);
13454 rt.shutdown();
13455 }
13456
13457 #[test]
13458 fn handle_spdp_datagram_skips_self_beacon() {
13459 let prefix = GuidPrefix::from_bytes([0x22; 12]);
13460 let rt = DcpsRuntime::start(42, prefix, RuntimeConfig::default()).expect("start");
13461 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13462 rt.attach_builtin_sinks(bs.sinks());
13463
13464 // Beacon from our own prefix → must be ignored (Spec
13465 // §8.5.4 self-discovery filter).
13466 let dg = make_remote_spdp_beacon(prefix);
13467 handle_spdp_datagram(&rt, &dg);
13468
13469 let reader = bs
13470 .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13471 "DCPSParticipant",
13472 )
13473 .unwrap();
13474 let samples = reader.take().unwrap();
13475 assert!(samples.is_empty(), "own beacon must not be logged");
13476 rt.shutdown();
13477 }
13478
13479 #[test]
13480 fn sedp_event_push_populates_publication_and_topic_readers() {
13481 use crate::builtin_topics as bt;
13482 use zerodds_discovery::sedp::SedpEvents;
13483 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13484 let rt = DcpsRuntime::start(
13485 43,
13486 GuidPrefix::from_bytes([0x23; 12]),
13487 RuntimeConfig::default(),
13488 )
13489 .expect("start");
13490 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13491 rt.attach_builtin_sinks(bs.sinks());
13492
13493 let mut events = SedpEvents::default();
13494 events.new_publications.push(
13495 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
13496 key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
13497 participant_key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
13498 topic_name: "WireT".into(),
13499 type_name: "WireType".into(),
13500 durability: zerodds_qos::DurabilityKind::Volatile,
13501 reliability: ReliabilityQosPolicy::default(),
13502 ownership: zerodds_qos::OwnershipKind::Shared,
13503 ownership_strength: 0,
13504 liveliness: LivelinessQosPolicy::default(),
13505 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13506 lifespan: zerodds_qos::LifespanQosPolicy::default(),
13507 partition: Vec::new(),
13508 user_data: Vec::new(),
13509 topic_data: Vec::new(),
13510 group_data: Vec::new(),
13511 type_information: None,
13512 data_representation: Vec::new(),
13513 security_info: None,
13514 service_instance_name: None,
13515 related_entity_guid: None,
13516 topic_aliases: None,
13517 type_identifier: zerodds_types::TypeIdentifier::None,
13518 unicast_locators: Vec::new(),
13519 multicast_locators: Vec::new(),
13520 },
13521 );
13522
13523 push_sedp_events_to_builtin_readers(&rt, &events);
13524
13525 let pub_reader = bs
13526 .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
13527 .unwrap();
13528 let pub_samples = pub_reader.take().unwrap();
13529 assert_eq!(pub_samples.len(), 1);
13530 assert_eq!(pub_samples[0].topic_name, "WireT");
13531
13532 let topic_reader = bs
13533 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13534 .unwrap();
13535 let topic_samples = topic_reader.take().unwrap();
13536 assert_eq!(topic_samples.len(), 1);
13537 assert_eq!(topic_samples[0].name, "WireT");
13538 rt.shutdown();
13539 }
13540
13541 #[test]
13542 fn sedp_event_push_populates_subscription_reader() {
13543 use crate::builtin_topics as bt;
13544 use zerodds_discovery::sedp::SedpEvents;
13545 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13546 let rt = DcpsRuntime::start(
13547 44,
13548 GuidPrefix::from_bytes([0x24; 12]),
13549 RuntimeConfig::default(),
13550 )
13551 .expect("start");
13552 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13553 rt.attach_builtin_sinks(bs.sinks());
13554
13555 let mut events = SedpEvents::default();
13556 events.new_subscriptions.push(
13557 zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
13558 key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
13559 participant_key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
13560 topic_name: "SubT".into(),
13561 type_name: "SubType".into(),
13562 durability: zerodds_qos::DurabilityKind::Volatile,
13563 reliability: ReliabilityQosPolicy::default(),
13564 ownership: zerodds_qos::OwnershipKind::Shared,
13565 liveliness: LivelinessQosPolicy::default(),
13566 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13567 partition: Vec::new(),
13568 user_data: Vec::new(),
13569 topic_data: Vec::new(),
13570 group_data: Vec::new(),
13571 type_information: None,
13572 data_representation: Vec::new(),
13573 content_filter: None,
13574 security_info: None,
13575 service_instance_name: None,
13576 related_entity_guid: None,
13577 topic_aliases: None,
13578 type_identifier: zerodds_types::TypeIdentifier::None,
13579 unicast_locators: Vec::new(),
13580 multicast_locators: Vec::new(),
13581 },
13582 );
13583
13584 push_sedp_events_to_builtin_readers(&rt, &events);
13585
13586 let sub_reader = bs
13587 .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
13588 .unwrap();
13589 let sub_samples = sub_reader.take().unwrap();
13590 assert_eq!(sub_samples.len(), 1);
13591 assert_eq!(sub_samples[0].topic_name, "SubT");
13592
13593 // The topic reader gets a synthetic topic sample also from
13594 // Subscription.
13595 let topic_reader = bs
13596 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13597 .unwrap();
13598 let topic_samples = topic_reader.take().unwrap();
13599 assert_eq!(topic_samples.len(), 1);
13600 assert_eq!(topic_samples[0].name, "SubT");
13601 rt.shutdown();
13602 }
13603
13604 #[test]
13605 fn push_sedp_events_to_builtin_readers_is_noop_without_sinks() {
13606 use zerodds_discovery::sedp::SedpEvents;
13607 let rt = DcpsRuntime::start(
13608 45,
13609 GuidPrefix::from_bytes([0x25; 12]),
13610 RuntimeConfig::default(),
13611 )
13612 .expect("start");
13613 // No attach_builtin_sinks → push must stay silent, not
13614 // panic.
13615 let events = SedpEvents::default();
13616 push_sedp_events_to_builtin_readers(&rt, &events);
13617 rt.shutdown();
13618 }
13619
13620 // ---- Ignore-Filter im Discovery-Hot-Path -------------
13621
13622 #[test]
13623 fn handle_spdp_datagram_drops_ignored_participant_beacon() {
13624 // Spec §2.2.2.2.1.14: ein einmal ignorierter Participant
13625 // taucht in keinem nachfolgenden Builtin-Sample mehr auf.
13626 let rt = DcpsRuntime::start(
13627 46,
13628 GuidPrefix::from_bytes([0x26; 12]),
13629 RuntimeConfig::default(),
13630 )
13631 .expect("start");
13632 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13633 rt.attach_builtin_sinks(bs.sinks());
13634 let filter = crate::participant::IgnoreFilter::default();
13635 rt.attach_ignore_filter(filter.clone());
13636
13637 let remote = GuidPrefix::from_bytes([0xAA; 12]);
13638 // Derive the ignore handle from the future beacon — we
13639 // know that the builtin sample key is the GUID of the remote
13640 // participant (=prefix + EntityId::PARTICIPANT).
13641 let key = Guid::new(remote, EntityId::PARTICIPANT);
13642 let h = crate::instance_handle::InstanceHandle::from_guid(key);
13643 if let Ok(mut s) = filter.inner.participants.lock() {
13644 s.insert(h);
13645 }
13646 let dg = make_remote_spdp_beacon(remote);
13647 handle_spdp_datagram(&rt, &dg);
13648
13649 let reader = bs
13650 .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
13651 "DCPSParticipant",
13652 )
13653 .unwrap();
13654 assert!(
13655 reader.take().unwrap().is_empty(),
13656 "an ignored participant must not land in DCPSParticipant"
13657 );
13658 rt.shutdown();
13659 }
13660
13661 #[test]
13662 fn sedp_event_push_filters_ignored_publication() {
13663 use crate::builtin_topics as bt;
13664 use zerodds_discovery::sedp::SedpEvents;
13665 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13666 let rt = DcpsRuntime::start(
13667 47,
13668 GuidPrefix::from_bytes([0x27; 12]),
13669 RuntimeConfig::default(),
13670 )
13671 .expect("start");
13672 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13673 rt.attach_builtin_sinks(bs.sinks());
13674 let filter = crate::participant::IgnoreFilter::default();
13675 rt.attach_ignore_filter(filter.clone());
13676
13677 let pub_key = Guid::new(GuidPrefix::from_bytes([0x33; 12]), EntityId::PARTICIPANT);
13678 let h_pub = crate::instance_handle::InstanceHandle::from_guid(pub_key);
13679 if let Ok(mut s) = filter.inner.publications.lock() {
13680 s.insert(h_pub);
13681 }
13682
13683 let mut events = SedpEvents::default();
13684 events.new_publications.push(
13685 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
13686 key: pub_key,
13687 participant_key: Guid::new(
13688 GuidPrefix::from_bytes([0x33; 12]),
13689 EntityId::PARTICIPANT,
13690 ),
13691 topic_name: "Filtered".into(),
13692 type_name: "T".into(),
13693 durability: zerodds_qos::DurabilityKind::Volatile,
13694 reliability: ReliabilityQosPolicy::default(),
13695 ownership: zerodds_qos::OwnershipKind::Shared,
13696 ownership_strength: 0,
13697 liveliness: LivelinessQosPolicy::default(),
13698 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13699 lifespan: zerodds_qos::LifespanQosPolicy::default(),
13700 partition: Vec::new(),
13701 user_data: Vec::new(),
13702 topic_data: Vec::new(),
13703 group_data: Vec::new(),
13704 type_information: None,
13705 data_representation: Vec::new(),
13706 security_info: None,
13707 service_instance_name: None,
13708 related_entity_guid: None,
13709 topic_aliases: None,
13710 type_identifier: zerodds_types::TypeIdentifier::None,
13711 unicast_locators: Vec::new(),
13712 multicast_locators: Vec::new(),
13713 },
13714 );
13715
13716 push_sedp_events_to_builtin_readers(&rt, &events);
13717
13718 let pub_reader = bs
13719 .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
13720 .unwrap();
13721 assert!(
13722 pub_reader.take().unwrap().is_empty(),
13723 "an ignored publication must not land in DCPSPublication"
13724 );
13725 // The synthetic DCPSTopic sample too must not be
13726 // forwarded, because the publication is completely
13727 // discarded.
13728 let topic_reader = bs
13729 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13730 .unwrap();
13731 assert!(topic_reader.take().unwrap().is_empty());
13732 rt.shutdown();
13733 }
13734
13735 #[test]
13736 fn sedp_event_push_filters_ignored_subscription() {
13737 use crate::builtin_topics as bt;
13738 use zerodds_discovery::sedp::SedpEvents;
13739 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13740 let rt = DcpsRuntime::start(
13741 48,
13742 GuidPrefix::from_bytes([0x28; 12]),
13743 RuntimeConfig::default(),
13744 )
13745 .expect("start");
13746 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13747 rt.attach_builtin_sinks(bs.sinks());
13748 let filter = crate::participant::IgnoreFilter::default();
13749 rt.attach_ignore_filter(filter.clone());
13750
13751 let sub_key = Guid::new(GuidPrefix::from_bytes([0x44; 12]), EntityId::PARTICIPANT);
13752 let h_sub = crate::instance_handle::InstanceHandle::from_guid(sub_key);
13753 if let Ok(mut s) = filter.inner.subscriptions.lock() {
13754 s.insert(h_sub);
13755 }
13756
13757 let mut events = SedpEvents::default();
13758 events.new_subscriptions.push(
13759 zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
13760 key: sub_key,
13761 participant_key: Guid::new(
13762 GuidPrefix::from_bytes([0x44; 12]),
13763 EntityId::PARTICIPANT,
13764 ),
13765 topic_name: "FilteredSub".into(),
13766 type_name: "T".into(),
13767 durability: zerodds_qos::DurabilityKind::Volatile,
13768 reliability: ReliabilityQosPolicy::default(),
13769 ownership: zerodds_qos::OwnershipKind::Shared,
13770 liveliness: LivelinessQosPolicy::default(),
13771 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13772 partition: Vec::new(),
13773 user_data: Vec::new(),
13774 topic_data: Vec::new(),
13775 group_data: Vec::new(),
13776 type_information: None,
13777 data_representation: Vec::new(),
13778 content_filter: None,
13779 security_info: None,
13780 service_instance_name: None,
13781 related_entity_guid: None,
13782 topic_aliases: None,
13783 type_identifier: zerodds_types::TypeIdentifier::None,
13784 unicast_locators: Vec::new(),
13785 multicast_locators: Vec::new(),
13786 },
13787 );
13788
13789 push_sedp_events_to_builtin_readers(&rt, &events);
13790
13791 let sub_reader = bs
13792 .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
13793 .unwrap();
13794 assert!(sub_reader.take().unwrap().is_empty());
13795 rt.shutdown();
13796 }
13797
13798 #[test]
13799 fn sedp_event_push_filters_ignored_topic_only() {
13800 // If only the topic is ignored, DCPSPublication should
13801 // still be pushed — only the DCPSTopic sample falls
13802 // away.
13803 use crate::builtin_topics as bt;
13804 use zerodds_discovery::sedp::SedpEvents;
13805 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
13806 let rt = DcpsRuntime::start(
13807 49,
13808 GuidPrefix::from_bytes([0x29; 12]),
13809 RuntimeConfig::default(),
13810 )
13811 .expect("start");
13812 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
13813 rt.attach_builtin_sinks(bs.sinks());
13814 let filter = crate::participant::IgnoreFilter::default();
13815 rt.attach_ignore_filter(filter.clone());
13816
13817 let topic_key =
13818 crate::builtin_topics::TopicBuiltinTopicData::synthesize_key("OnlyTopic", "T");
13819 let h_topic = crate::instance_handle::InstanceHandle::from_guid(topic_key);
13820 if let Ok(mut s) = filter.inner.topics.lock() {
13821 s.insert(h_topic);
13822 }
13823
13824 let mut events = SedpEvents::default();
13825 events.new_publications.push(
13826 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
13827 key: Guid::new(GuidPrefix::from_bytes([0x55; 12]), EntityId::PARTICIPANT),
13828 participant_key: Guid::new(
13829 GuidPrefix::from_bytes([0x55; 12]),
13830 EntityId::PARTICIPANT,
13831 ),
13832 topic_name: "OnlyTopic".into(),
13833 type_name: "T".into(),
13834 durability: zerodds_qos::DurabilityKind::Volatile,
13835 reliability: ReliabilityQosPolicy::default(),
13836 ownership: zerodds_qos::OwnershipKind::Shared,
13837 ownership_strength: 0,
13838 liveliness: LivelinessQosPolicy::default(),
13839 deadline: zerodds_qos::DeadlineQosPolicy::default(),
13840 lifespan: zerodds_qos::LifespanQosPolicy::default(),
13841 partition: Vec::new(),
13842 user_data: Vec::new(),
13843 topic_data: Vec::new(),
13844 group_data: Vec::new(),
13845 type_information: None,
13846 data_representation: Vec::new(),
13847 security_info: None,
13848 service_instance_name: None,
13849 related_entity_guid: None,
13850 topic_aliases: None,
13851 type_identifier: zerodds_types::TypeIdentifier::None,
13852 unicast_locators: Vec::new(),
13853 multicast_locators: Vec::new(),
13854 },
13855 );
13856
13857 push_sedp_events_to_builtin_readers(&rt, &events);
13858
13859 let pub_reader = bs
13860 .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
13861 .unwrap();
13862 assert_eq!(pub_reader.take().unwrap().len(), 1);
13863 let topic_reader = bs
13864 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
13865 .unwrap();
13866 assert!(
13867 topic_reader.take().unwrap().is_empty(),
13868 "an ignored topic may block the synthetic DCPSTopic sample"
13869 );
13870 rt.shutdown();
13871 }
13872
13873 // -------- Security-Builtin-Endpoint-Wiring --------
13874
13875 /// Creates an SPDP beacon with configurable BuiltinEndpoint
13876 /// bits. Extension of [`make_remote_spdp_beacon`] with
13877 /// flag-Argument (Security-Bits 22..25).
13878 fn make_remote_spdp_beacon_with_flags(remote_prefix: GuidPrefix, endpoint_set: u32) -> Vec<u8> {
13879 use zerodds_discovery::spdp::SpdpBeacon;
13880 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
13881 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
13882 let data = ParticipantBuiltinTopicData {
13883 guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
13884 protocol_version: ProtocolVersion::V2_5,
13885 vendor_id: VendorId::ZERODDS,
13886 default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
13887 default_multicast_locator: None,
13888 metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
13889 metatraffic_multicast_locator: None,
13890 domain_id: Some(0),
13891 builtin_endpoint_set: endpoint_set,
13892 lease_duration: QosDuration::from_secs(100),
13893 user_data: alloc::vec::Vec::new(),
13894 properties: Default::default(),
13895 identity_token: None,
13896 permissions_token: None,
13897 identity_status_token: None,
13898 sig_algo_info: None,
13899 kx_algo_info: None,
13900 sym_cipher_algo_info: None,
13901 participant_security_info: None,
13902 };
13903 let mut beacon = SpdpBeacon::new(data);
13904 beacon.serialize().expect("serialize")
13905 }
13906
13907 fn dp_with_locators(
13908 prefix: GuidPrefix,
13909 metatraffic: Option<Locator>,
13910 default: Option<Locator>,
13911 ) -> zerodds_discovery::spdp::DiscoveredParticipant {
13912 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
13913 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
13914 zerodds_discovery::spdp::DiscoveredParticipant {
13915 sender_prefix: prefix,
13916 sender_vendor: VendorId::ZERODDS,
13917 data: ParticipantBuiltinTopicData {
13918 guid: Guid::new(prefix, EntityId::PARTICIPANT),
13919 protocol_version: ProtocolVersion::V2_5,
13920 vendor_id: VendorId::ZERODDS,
13921 default_unicast_locator: default,
13922 default_multicast_locator: None,
13923 metatraffic_unicast_locator: metatraffic,
13924 metatraffic_multicast_locator: None,
13925 domain_id: Some(0),
13926 builtin_endpoint_set: 0,
13927 lease_duration: QosDuration::from_secs(100),
13928 user_data: alloc::vec::Vec::new(),
13929 properties: Default::default(),
13930 identity_token: None,
13931 permissions_token: None,
13932 identity_status_token: None,
13933 sig_algo_info: None,
13934 kx_algo_info: None,
13935 sym_cipher_algo_info: None,
13936 participant_security_info: None,
13937 },
13938 }
13939 }
13940
13941 #[test]
13942 fn wlp_unicast_targets_prefers_metatraffic_then_default() {
13943 // M-2: WLP-Unicast-Fan-out waehlt pro Peer metatraffic_unicast (bevorzugt),
13944 // otherwise default_unicast; peers without a routable locator fall out.
13945 let meta = Locator::udp_v4([127, 0, 0, 1], 7501);
13946 let deflt = Locator::udp_v4([127, 0, 0, 2], 7500);
13947 let peers = alloc::vec![
13948 // (a) has metatraffic → metatraffic wins
13949 dp_with_locators(GuidPrefix::from_bytes([1; 12]), Some(meta), Some(deflt)),
13950 // (b) only default → default
13951 dp_with_locators(GuidPrefix::from_bytes([2; 12]), None, Some(deflt)),
13952 // (c) none at all → no target
13953 dp_with_locators(GuidPrefix::from_bytes([3; 12]), None, None),
13954 ];
13955 let targets = wlp_unicast_targets(&peers);
13956 assert_eq!(targets, alloc::vec![meta, deflt]);
13957 }
13958
13959 /// Like [`make_remote_spdp_beacon_with_flags`], but with a set
13960 /// `identity_token` (FU2 Gap 7d — triggers the auth handshake).
13961 #[cfg(feature = "security")]
13962 fn make_secure_beacon_with_identity_token(
13963 remote_prefix: GuidPrefix,
13964 endpoint_set: u32,
13965 identity_token: Vec<u8>,
13966 ) -> Vec<u8> {
13967 use zerodds_discovery::spdp::SpdpBeacon;
13968 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
13969 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
13970 let data = ParticipantBuiltinTopicData {
13971 guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
13972 protocol_version: ProtocolVersion::V2_5,
13973 vendor_id: VendorId::ZERODDS,
13974 default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
13975 default_multicast_locator: None,
13976 metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
13977 metatraffic_multicast_locator: None,
13978 domain_id: Some(0),
13979 builtin_endpoint_set: endpoint_set,
13980 lease_duration: QosDuration::from_secs(100),
13981 user_data: alloc::vec::Vec::new(),
13982 properties: Default::default(),
13983 identity_token: Some(identity_token),
13984 permissions_token: None,
13985 identity_status_token: None,
13986 sig_algo_info: None,
13987 kx_algo_info: None,
13988 sym_cipher_algo_info: None,
13989 participant_security_info: None,
13990 };
13991 let mut beacon = SpdpBeacon::new(data);
13992 beacon.serialize().expect("serialize")
13993 }
13994
13995 /// Minimal auth plugin for the FU2 wiring tests (Gap 4/7).
13996 /// Crypto correctness is verified in the stack.rs driver test; here
13997 /// it is only about the runtime wiring path.
13998 #[cfg(feature = "security")]
13999 struct FakeAuth;
14000 #[cfg(feature = "security")]
14001 impl zerodds_security::authentication::AuthenticationPlugin for FakeAuth {
14002 fn validate_local_identity(
14003 &mut self,
14004 _: &zerodds_security::properties::PropertyList,
14005 _: [u8; 16],
14006 ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
14007 {
14008 Ok(zerodds_security::authentication::IdentityHandle(1))
14009 }
14010 fn validate_remote_identity(
14011 &mut self,
14012 _: zerodds_security::authentication::IdentityHandle,
14013 _: [u8; 16],
14014 _: &[u8],
14015 ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
14016 {
14017 Ok(zerodds_security::authentication::IdentityHandle(2))
14018 }
14019 fn begin_handshake_request(
14020 &mut self,
14021 _: zerodds_security::authentication::IdentityHandle,
14022 _: zerodds_security::authentication::IdentityHandle,
14023 ) -> zerodds_security::error::SecurityResult<(
14024 zerodds_security::authentication::HandshakeHandle,
14025 zerodds_security::authentication::HandshakeStepOutcome,
14026 )> {
14027 Ok((
14028 zerodds_security::authentication::HandshakeHandle(1),
14029 zerodds_security::authentication::HandshakeStepOutcome::SendMessage {
14030 token: zerodds_security::token::DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")
14031 .to_cdr_le(),
14032 },
14033 ))
14034 }
14035 fn begin_handshake_reply(
14036 &mut self,
14037 _: zerodds_security::authentication::IdentityHandle,
14038 _: zerodds_security::authentication::IdentityHandle,
14039 _: &[u8],
14040 ) -> zerodds_security::error::SecurityResult<(
14041 zerodds_security::authentication::HandshakeHandle,
14042 zerodds_security::authentication::HandshakeStepOutcome,
14043 )> {
14044 Ok((
14045 zerodds_security::authentication::HandshakeHandle(2),
14046 zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer,
14047 ))
14048 }
14049 fn process_handshake(
14050 &mut self,
14051 _: zerodds_security::authentication::HandshakeHandle,
14052 _: &[u8],
14053 ) -> zerodds_security::error::SecurityResult<
14054 zerodds_security::authentication::HandshakeStepOutcome,
14055 > {
14056 Ok(zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer)
14057 }
14058 fn shared_secret(
14059 &self,
14060 _: zerodds_security::authentication::HandshakeHandle,
14061 ) -> zerodds_security::error::SecurityResult<
14062 zerodds_security::authentication::SharedSecretHandle,
14063 > {
14064 Err(zerodds_security::error::SecurityError::new(
14065 zerodds_security::error::SecurityErrorKind::BadArgument,
14066 "fake: handshake not complete",
14067 ))
14068 }
14069 fn plugin_class_id(&self) -> &str {
14070 "FAKE:Auth:1.0"
14071 }
14072 fn get_identity_token(
14073 &self,
14074 _: zerodds_security::authentication::IdentityHandle,
14075 ) -> zerodds_security::error::SecurityResult<Vec<u8>> {
14076 // Non-empty Token (Format irrelevant — FakeAuth.validate_remote_
14077 // identity accepts everything); only so the beacon-populate path
14078 // (Gap 7c) has something to announce.
14079 Ok(alloc::vec![0xAB, 0xCD, 0xEF, 0x01])
14080 }
14081 fn get_permissions_token(&self) -> Vec<u8> {
14082 // Non-empty PermissionsToken, so the beacon-populate path
14083 // (S4 point 1) has something to announce (format irrelevant).
14084 zerodds_security::token::DataHolder::new("DDS:Access:Permissions:1.0").to_cdr_le()
14085 }
14086 }
14087
14088 /// Consolidated test for the wiring. A single
14089 /// runtime walks all paths — snapshot API, idempotency of
14090 /// `enable_security_builtins`, SPDP hot path with security bits,
14091 /// without bits, plus the wire-demux hook. We bundle this into one
14092 /// test body, because each `DcpsRuntime::start` binds a multicast socket
14093 /// and parallel tests could brush against the OS resource caps.
14094 #[test]
14095 fn c34c_security_builtin_wiring_end_to_end() {
14096 use zerodds_discovery::security::SecurityBuiltinStack;
14097 use zerodds_security::generic_message::{
14098 MessageIdentity, ParticipantGenericMessage, class_id,
14099 };
14100 use zerodds_security::token::DataHolder;
14101
14102 let local_prefix = GuidPrefix::from_bytes([0x75; 12]);
14103 let rt = DcpsRuntime::start(75, local_prefix, RuntimeConfig::default()).expect("start");
14104
14105 // 1. Snapshot is None before enable
14106 assert!(rt.security_builtin_snapshot().is_none());
14107
14108 // 2. enable ist idempotent
14109 let h1 = rt.enable_security_builtins(VendorId::ZERODDS);
14110 let h2 = rt.enable_security_builtins(VendorId::ZERODDS);
14111 assert!(Arc::ptr_eq(&h1, &h2));
14112 assert!(rt.security_builtin_snapshot().is_some());
14113
14114 // 3. SPDP beacon with all security-builtin bits → the stack has
14115 // four proxies
14116 let remote_a = GuidPrefix::from_bytes([0x99; 12]);
14117 let flags_all = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14118 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
14119 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
14120 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
14121 handle_spdp_datagram(
14122 &rt,
14123 &make_remote_spdp_beacon_with_flags(remote_a, flags_all),
14124 );
14125 {
14126 let s = h1.lock().unwrap();
14127 assert_eq!(s.stateless_writer.reader_proxy_count(), 1);
14128 assert_eq!(s.stateless_reader.writer_proxy_count(), 1);
14129 assert_eq!(s.volatile_writer.reader_proxy_count(), 1);
14130 assert_eq!(s.volatile_reader.writer_proxy_count(), 1);
14131 }
14132
14133 // 4. SPDP beacon without security bits → the stack stays unchanged
14134 let remote_b = GuidPrefix::from_bytes([0x88; 12]);
14135 handle_spdp_datagram(
14136 &rt,
14137 &make_remote_spdp_beacon_with_flags(remote_b, endpoint_flag::ALL_STANDARD),
14138 );
14139 {
14140 let s = h1.lock().unwrap();
14141 assert_eq!(
14142 s.stateless_writer.reader_proxy_count(),
14143 1,
14144 "a peer without security bits must not touch existing proxies"
14145 );
14146 }
14147
14148 // 5. Wire-demux hook with a valid stateless DATA: remote-stack
14149 // mirror sends a message → the demux hook routes it through
14150 // the local reader without panic.
14151 let mut remote_stack = SecurityBuiltinStack::new(remote_a, VendorId::ZERODDS);
14152 let local_peer = make_remote_spdp_beacon_with_flags(local_prefix, flags_all);
14153 let parsed_local = zerodds_discovery::spdp::SpdpReader::new()
14154 .parse_datagram(&local_peer)
14155 .unwrap();
14156 remote_stack.handle_remote_endpoints(&parsed_local);
14157 let msg = ParticipantGenericMessage {
14158 message_identity: MessageIdentity {
14159 source_guid: [0xCD; 16],
14160 sequence_number: 1,
14161 },
14162 related_message_identity: MessageIdentity::default(),
14163 destination_participant_key: [0xEF; 16],
14164 destination_endpoint_key: [0; 16],
14165 source_endpoint_key: [0xFE; 16],
14166 message_class_id: class_id::AUTH_REQUEST.into(),
14167 message_data: alloc::vec![DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")],
14168 };
14169 let dgs = remote_stack.stateless_writer.write(&msg).unwrap();
14170 assert_eq!(dgs.len(), 1);
14171 dispatch_security_builtin_datagram(&rt, &dgs[0].bytes, Duration::from_secs(1));
14172
14173 // 6. The demux hook does not panic on garbage bytes
14174 dispatch_security_builtin_datagram(&rt, &[0u8; 32], Duration::from_secs(1));
14175
14176 rt.shutdown();
14177 }
14178
14179 /// FU2 Gap 4: `enable_security_builtins_with_auth` builds the stack with
14180 /// an active handshake driver — `begin_handshake_with` sends, as
14181 /// the initiator actually sends an AUTH_REQUEST (instead of a no-op like with
14182 /// the auth-less `enable_security_builtins`).
14183 #[cfg(feature = "security")]
14184 #[test]
14185 fn enable_security_builtins_with_auth_activates_handshake_driver() {
14186 use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14187
14188 let local_prefix = GuidPrefix::from_bytes([0x40; 12]);
14189 let rt = DcpsRuntime::start(40, local_prefix, RuntimeConfig::default()).expect("start");
14190
14191 let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14192 let stack =
14193 rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
14194
14195 // Discover a peer with stateless bits (WITHOUT identity_token → the
14196 // discovery trigger starts no handshake yet) → proxies
14197 // are wired. The remote prefix is LARGER than local ([0x40]),
14198 // so that local is the initiator under the cyclone convention (smaller GUID
14199 // initiates) and actually sends.
14200 let remote = GuidPrefix::from_bytes([0x99; 12]);
14201 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14202 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14203 handle_spdp_datagram(&rt, &make_remote_spdp_beacon_with_flags(remote, flags));
14204
14205 let dgs = {
14206 let mut s = stack.lock().unwrap();
14207 let remote_guid = Guid::new(remote, EntityId::PARTICIPANT).to_bytes();
14208 s.begin_handshake_with(remote, remote_guid, b"fake-remote-cert-der")
14209 .expect("begin_handshake_with")
14210 };
14211 assert_eq!(
14212 dgs.len(),
14213 1,
14214 "auth driver active → the initiator sends exactly one AUTH_REQUEST"
14215 );
14216
14217 rt.shutdown();
14218 }
14219
14220 /// FU2 Gap 7c/d: `enable_security_builtins_with_auth` announces the
14221 /// local `identity_token` in the SPDP beacon (+ stateless/volatile bits),
14222 /// and an incoming peer beacon WITH an `identity_token` kicks off the
14223 /// Auth-Handshake an (Discovery-Trigger).
14224 #[cfg(feature = "security")]
14225 #[test]
14226 fn spdp_beacon_announces_identity_token_and_discovery_triggers_handshake() {
14227 use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14228
14229 let local_prefix = GuidPrefix::from_bytes([0x41; 12]);
14230 let rt = DcpsRuntime::start(41, local_prefix, RuntimeConfig::default()).expect("start");
14231 let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14232 let stack =
14233 rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
14234
14235 // Gap 7c: the beacon now announces identity_token + secure bits.
14236 let beacon_bytes = rt.spdp_beacon.lock().unwrap().serialize().unwrap();
14237 let parsed = zerodds_discovery::spdp::SpdpReader::new()
14238 .parse_datagram(&beacon_bytes)
14239 .unwrap();
14240 assert!(
14241 parsed.data.identity_token.is_some(),
14242 "the beacon must announce PID_IDENTITY_TOKEN"
14243 );
14244 // Cross-vendor: secure vendors validate a remote only when
14245 // SPDP carries **both** tokens. Without PID_PERMISSIONS_TOKEN they treat
14246 // cyclone treats us as non-secure and never starts validate_remote_identity.
14247 assert!(
14248 parsed.data.permissions_token.is_some(),
14249 "the beacon must announce PID_PERMISSIONS_TOKEN (cross-vendor mandatory)"
14250 );
14251 assert_ne!(
14252 parsed.data.builtin_endpoint_set & endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER,
14253 0,
14254 "the beacon must announce the stateless-auth bit"
14255 );
14256
14257 // Gap 7d: peer beacon WITH identity_token + stateless bits → the
14258 // discovery path kicks off begin_handshake_with.
14259 let remote = GuidPrefix::from_bytes([0x99; 12]);
14260 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14261 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14262 let peer_beacon =
14263 make_secure_beacon_with_identity_token(remote, flags, alloc::vec![0x11, 0x22, 0x33]);
14264 handle_spdp_datagram(&rt, &peer_beacon);
14265
14266 // Proof that the discovery trigger fired: the peer is now
14267 // registered in the stack's handshake state. (The earlier length
14268 // probe via a repeated begin_handshake_with no longer applies since the resend path
14269 // resends as the initiator on a repeated call.)
14270 let started = {
14271 let s = stack.lock().unwrap();
14272 s.handshake_peer_count()
14273 };
14274 assert_eq!(
14275 started, 1,
14276 "the discovery trigger must have started the handshake (peer registered)"
14277 );
14278
14279 rt.shutdown();
14280 }
14281
14282 /// FU2 S3: two secure runtimes in the same process MUST find each other via
14283 /// in-process participant discovery and kick off the auth handshake
14284 /// — WITHOUT a single multicast beacon. That was exactly missing:
14285 /// `inproc_inject_publication`/`_subscription` inject only SEDP, the
14286 /// SPDP participant discovery (identity_token + `begin_handshake_with`)
14287 /// ran exclusively over the flaky multicast path.
14288 #[cfg(feature = "security")]
14289 #[test]
14290 fn inproc_participant_discovery_triggers_handshake_without_multicast() {
14291 use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
14292
14293 let a_prefix = GuidPrefix::from_bytes([0x4A; 12]);
14294 let b_prefix = GuidPrefix::from_bytes([0x4B; 12]);
14295 let rt_a = DcpsRuntime::start(47, a_prefix, RuntimeConfig::default()).expect("start a");
14296 let rt_b = DcpsRuntime::start(47, b_prefix, RuntimeConfig::default()).expect("start b");
14297 let auth_a: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14298 let auth_b: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
14299 let stack_a =
14300 rt_a.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_a, IdentityHandle(1));
14301 let stack_b =
14302 rt_b.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_b, IdentityHandle(1));
14303
14304 // KEIN handle_spdp_datagram / Multicast — rein in-process.
14305 let a_peers = stack_a.lock().unwrap().handshake_peer_count();
14306 let b_peers = stack_b.lock().unwrap().handshake_peer_count();
14307 assert!(
14308 a_peers >= 1,
14309 "A must have discovered B in-process + started the handshake (got {a_peers})"
14310 );
14311 assert!(
14312 b_peers >= 1,
14313 "B must have discovered A in-process + started the handshake (got {b_peers})"
14314 );
14315
14316 rt_a.shutdown();
14317 rt_b.shutdown();
14318 }
14319
14320 /// Mints a shared CA + two leaf identities (PEM) for the
14321 /// FU2-Handshake-e2e-Test.
14322 #[cfg(feature = "security")]
14323 #[allow(clippy::type_complexity)]
14324 fn mint_handshake_identities() -> ((Vec<u8>, Vec<u8>), (Vec<u8>, Vec<u8>)) {
14325 use rcgen::{CertificateParams, KeyPair};
14326 let mut ca_params =
14327 CertificateParams::new(alloc::vec![alloc::string::String::from("FU2 CA")]).unwrap();
14328 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
14329 let ca_key = KeyPair::generate().unwrap();
14330 let ca_cert = ca_params.self_signed(&ca_key).unwrap();
14331 let ca_pem = ca_cert.pem().into_bytes();
14332 let mint = |name: &str| -> (Vec<u8>, Vec<u8>) {
14333 let mut p =
14334 CertificateParams::new(alloc::vec![alloc::string::String::from(name)]).unwrap();
14335 p.is_ca = rcgen::IsCa::NoCa;
14336 let k = KeyPair::generate().unwrap();
14337 let c = p.signed_by(&k, &ca_cert, &ca_key).unwrap();
14338 (c.pem().into_bytes(), k.serialize_pem().into_bytes())
14339 };
14340 let alice = {
14341 let (cert, key) = mint("alice");
14342 (cert, key)
14343 };
14344 let bob = {
14345 let (cert, key) = mint("bob");
14346 (cert, key)
14347 };
14348 // attach ca_pem to both, so the caller has the trust anchor.
14349 (
14350 ([alice.0, b"\n".to_vec(), ca_pem.clone()].concat(), alice.1),
14351 ([bob.0, b"\n".to_vec(), ca_pem].concat(), bob.1),
14352 )
14353 }
14354
14355 /// FU2 Gap 5 (e2e): a runtime replier (A) and an in-test initiator
14356 /// stack (B) complete a real PKI 3-round handshake via the dispatch path
14357 /// and BOTH derive the same SharedSecret.
14358 /// Verifies the dispatch wiring (`on_stateless_message` →
14359 /// reply/final → completion) in the real runtime context.
14360 #[cfg(feature = "security")]
14361 #[test]
14362 fn handshake_completes_through_runtime_dispatch_e2e() {
14363 use zerodds_discovery::security::SecurityBuiltinStack;
14364 use zerodds_security::authentication::AuthenticationPlugin;
14365 use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
14366
14367 // cert_pem here contains Leaf || CA (mint_handshake_identities),
14368 // identity_ca_pem = the same bundle (CA is included).
14369 let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
14370
14371 // A = Runtime (Replier, HOEHERER Prefix). B = in-test Stack
14372 // (initiator, LOWER prefix) — cyclone convention: smaller
14373 // GUID initiiert.
14374 let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
14375 let b_prefix = GuidPrefix::from_bytes([0x10; 12]);
14376 let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
14377 let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
14378
14379 // --- A: runtime with a real PKI plugin ---
14380 let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14381 let a_local = a_pki
14382 .lock()
14383 .unwrap()
14384 .validate_with_config(
14385 IdentityConfig {
14386 identity_cert_pem: a_cert.clone(),
14387 identity_ca_pem: a_cert.clone(),
14388 identity_key_pem: Some(a_key),
14389 },
14390 a_guid,
14391 )
14392 .unwrap();
14393 let a_token = a_pki.lock().unwrap().get_identity_token(a_local).unwrap();
14394 let rt = DcpsRuntime::start(42, a_prefix, RuntimeConfig::default()).expect("start");
14395 let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
14396 let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
14397
14398 // --- B: in-test initiator stack with a real PKI plugin ---
14399 let b_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14400 let b_local = b_pki
14401 .lock()
14402 .unwrap()
14403 .validate_with_config(
14404 IdentityConfig {
14405 identity_cert_pem: b_cert.clone(),
14406 identity_ca_pem: b_cert.clone(),
14407 identity_key_pem: Some(b_key),
14408 },
14409 b_guid,
14410 )
14411 .unwrap();
14412 let b_token = b_pki.lock().unwrap().get_identity_token(b_local).unwrap();
14413 let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = b_pki.clone();
14414 let mut b_stack =
14415 SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
14416
14417 let stateless = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14418 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14419
14420 // B discovers A (wired proxies) — via the parsed A beacon.
14421 let a_beacon = make_secure_beacon_with_identity_token(a_prefix, stateless, a_token.clone());
14422 let a_parsed = zerodds_discovery::spdp::SpdpReader::new()
14423 .parse_datagram(&a_beacon)
14424 .unwrap();
14425 b_stack.handle_remote_endpoints(&a_parsed);
14426
14427 // A discovers B → the discovery trigger creates A's peer state (A is
14428 // the replier, sends nothing).
14429 let b_beacon = make_secure_beacon_with_identity_token(b_prefix, stateless, b_token);
14430 handle_spdp_datagram(&rt, &b_beacon);
14431
14432 // B (initiator) starts → AUTH_REQUEST.
14433 let req = b_stack
14434 .begin_handshake_with(a_prefix, a_guid, &a_token)
14435 .unwrap();
14436 assert_eq!(req.len(), 1, "B sends AUTH_REQUEST");
14437
14438 // Pump: REQUEST → A.dispatch → REPLY.
14439 let reply = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
14440 assert_eq!(reply.len(), 1, "A (replier) answers with AUTH reply");
14441
14442 // REPLY → B verarbeitet → FINAL (+ B erreicht Complete).
14443 let b_msgs = b_stack
14444 .stateless_reader
14445 .handle_datagram(&reply[0].bytes)
14446 .unwrap();
14447 assert_eq!(b_msgs.len(), 1);
14448 let (final_dgs, _b_complete) = b_stack.on_stateless_message(a_prefix, &b_msgs[0]).unwrap();
14449 assert_eq!(final_dgs.len(), 1, "B sends AUTH-Final");
14450
14451 // FINAL → A.dispatch → A erreicht Complete.
14452 let _ =
14453 dispatch_security_builtin_datagram(&rt, &final_dgs[0].bytes, Duration::from_secs(1));
14454
14455 // Both sides must now have derived the same SharedSecret.
14456 let a_secret = {
14457 let s = a_stack.lock().unwrap();
14458 s.peer_secret(b_prefix)
14459 .expect("A must have authenticated B")
14460 };
14461 let b_secret = b_stack
14462 .peer_secret(a_prefix)
14463 .expect("B must have authenticated A");
14464 let a_bytes = a_pki
14465 .lock()
14466 .unwrap()
14467 .secret_bytes(a_secret)
14468 .unwrap()
14469 .to_vec();
14470 let b_bytes = b_pki
14471 .lock()
14472 .unwrap()
14473 .secret_bytes(b_secret)
14474 .unwrap()
14475 .to_vec();
14476 assert_eq!(a_bytes.len(), 32);
14477 assert_eq!(
14478 a_bytes, b_bytes,
14479 "runtime dispatch + in-test stack derive the same secret"
14480 );
14481
14482 rt.shutdown();
14483 }
14484
14485 /// FU2 S1.5 (e2e): after the auth handshake the runtime dispatch
14486 /// (A, replier) and a reference peer (B, stack+gate, initiator) over
14487 /// the Kx-protected VolatileSecure channel automatically exchange their data
14488 /// crypto tokens — afterwards secured user DATA round-trips in BOTH
14489 /// directions. **The secured-DATA proof via the runtime dispatch.**
14490 #[cfg(feature = "security")]
14491 #[test]
14492 #[serial_test::serial(dcps_security_e2e)]
14493 fn secured_data_round_trips_through_runtime_dispatch_e2e() {
14494 use zerodds_discovery::security::SecurityBuiltinStack;
14495 use zerodds_security::authentication::{AuthenticationPlugin, SharedSecretProvider};
14496 use zerodds_security::generic_message::{
14497 MessageIdentity, ParticipantGenericMessage, class_id,
14498 };
14499 use zerodds_security::token::DataHolder;
14500 use zerodds_security_crypto::{AesGcmCryptoPlugin, Suite};
14501 use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
14502 use zerodds_security_runtime::{ProtectionLevel, SharedSecurityGate};
14503
14504 // Couples the pki plugin (behind a mutex) as the SharedSecretProvider to
14505 // the crypto plugin — like SecurityProfile in the FFI (Gap 1).
14506 struct PkiProvider(Arc<Mutex<PkiAuthenticationPlugin>>);
14507 impl SharedSecretProvider for PkiProvider {
14508 fn get_shared_secret(
14509 &self,
14510 h: zerodds_security::authentication::SharedSecretHandle,
14511 ) -> Option<Vec<u8>> {
14512 self.0.lock().ok()?.get_shared_secret(h)
14513 }
14514 }
14515 const GOV: &str = r#"<domain_access_rules><domain_rule><domains><id>0</id></domains><rtps_protection_kind>ENCRYPT</rtps_protection_kind><topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules></domain_rule></domain_access_rules>"#;
14516 let gov = || zerodds_security_permissions::parse_governance_xml(GOV).unwrap();
14517 let gate_with = |pki: &Arc<Mutex<PkiAuthenticationPlugin>>| {
14518 SharedSecurityGate::new(
14519 0,
14520 gov(),
14521 Box::new(AesGcmCryptoPlugin::with_secret_provider(
14522 Suite::Aes128Gcm,
14523 Arc::new(PkiProvider(pki.clone())) as Arc<dyn SharedSecretProvider>,
14524 )),
14525 )
14526 };
14527 let fake_rtps = |prefix: GuidPrefix, body: &[u8]| -> Vec<u8> {
14528 let mut m = Vec::new();
14529 m.extend_from_slice(b"RTPS\x02\x05\x01\x02");
14530 m.extend_from_slice(&prefix.to_bytes());
14531 m.extend_from_slice(body);
14532 m
14533 };
14534
14535 let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
14536 let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
14537 let b_prefix = GuidPrefix::from_bytes([0x10; 12]); // B < A → B initiator (cyclone convention)
14538 let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
14539 let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
14540 let a_key_pk = a_prefix.to_bytes();
14541 let b_key_pk = b_prefix.to_bytes();
14542
14543 // --- A: runtime with auth + gate (sharing pki_a) ---
14544 let pki_a = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14545 let a_local = pki_a
14546 .lock()
14547 .unwrap()
14548 .validate_with_config(
14549 IdentityConfig {
14550 identity_cert_pem: a_cert.clone(),
14551 identity_ca_pem: a_cert.clone(),
14552 identity_key_pem: Some(a_key),
14553 },
14554 a_guid,
14555 )
14556 .unwrap();
14557 let a_token = pki_a.lock().unwrap().get_identity_token(a_local).unwrap();
14558 let gate_a = Arc::new(gate_with(&pki_a));
14559 let rt = DcpsRuntime::start(
14560 43,
14561 a_prefix,
14562 RuntimeConfig {
14563 security: Some(gate_a.clone()),
14564 ..RuntimeConfig::default()
14565 },
14566 )
14567 .expect("start");
14568 let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_a.clone();
14569 let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
14570
14571 // --- B: in-test Stack + Gate (sharing pki_b), Initiator ---
14572 let pki_b = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
14573 let b_local = pki_b
14574 .lock()
14575 .unwrap()
14576 .validate_with_config(
14577 IdentityConfig {
14578 identity_cert_pem: b_cert.clone(),
14579 identity_ca_pem: b_cert.clone(),
14580 identity_key_pem: Some(b_key),
14581 },
14582 b_guid,
14583 )
14584 .unwrap();
14585 let b_token = pki_b.lock().unwrap().get_identity_token(b_local).unwrap();
14586 let gate_b = gate_with(&pki_b);
14587 let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_b.clone();
14588 let mut stack_b =
14589 SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
14590
14591 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14592 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
14593 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
14594 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
14595 let a_beacon = make_secure_beacon_with_identity_token(a_prefix, flags, a_token.clone());
14596 stack_b.handle_remote_endpoints(
14597 &zerodds_discovery::spdp::SpdpReader::new()
14598 .parse_datagram(&a_beacon)
14599 .unwrap(),
14600 );
14601 // Wire A's stack deterministically (no handle_spdp_datagram —
14602 // a running runtime + trigger otherwise produces non-deterministic
14603 // proxy wirings via parallel/loopback beacons). A is the replier:
14604 // begin_handshake_with only sets up the peer state.
14605 let b_beacon = make_secure_beacon_with_identity_token(b_prefix, flags, b_token.clone());
14606 let b_parsed = zerodds_discovery::spdp::SpdpReader::new()
14607 .parse_datagram(&b_beacon)
14608 .unwrap();
14609 {
14610 let mut s = a_stack.lock().unwrap();
14611 s.handle_remote_endpoints(&b_parsed);
14612 s.begin_handshake_with(b_prefix, b_guid, &b_token).unwrap();
14613 }
14614
14615 // --- Stateless-Handshake pumpen (B initiiert) ---
14616 // A is the replier and derives the secret already at begin_handshake_
14617 // reply → A's response to the request contains BOTH: the
14618 // AUTH reply (stateless) AND A's Kx-encrypted crypto token
14619 // (volatile, automatically via the dispatch).
14620 let decode_route = |dgs: &[zerodds_rtps::message_builder::OutboundDatagram]| {
14621 let mut stateless = Vec::new();
14622 let mut volatile = Vec::new();
14623 for dg in dgs {
14624 let parsed = zerodds_rtps::datagram::decode_datagram(&dg.bytes).unwrap();
14625 let is_vol = parsed.submessages.iter().any(|sub| {
14626 // Klartext-Pfad (unprotected): DATA an den VolatileSecure-Reader.
14627 matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Data(d)
14628 if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER)
14629 // Cross-vendor path (protected): the volatile crypto-token DATA
14630 // is SEC_*-protected (protect_volatile_outbound) -> the inner
14631 // DATA is encrypted and recognizable only by the prepended SEC_PREFIX
14632 // submessage (id 0x31). Stateless AUTH stays plaintext.
14633 || matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Unknown { id: 0x31, .. })
14634 });
14635 if is_vol {
14636 volatile.push(dg.bytes.clone());
14637 } else {
14638 stateless.push(dg.bytes.clone());
14639 }
14640 }
14641 (stateless, volatile)
14642 };
14643
14644 let req = stack_b
14645 .begin_handshake_with(a_prefix, a_guid, &a_token)
14646 .unwrap();
14647 let a_resp = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
14648 let (a_stateless, a_volatile) = decode_route(&a_resp);
14649 assert!(
14650 !a_volatile.is_empty(),
14651 "A dispatch must send A's crypto token"
14652 );
14653
14654 // B verarbeitet A's AUTH-Reply → Final + B completes.
14655 let mut b_remote_id = None;
14656 let mut b_secret = None;
14657 let mut b_final = Vec::new();
14658 for sl in &a_stateless {
14659 for m in stack_b.stateless_reader.handle_datagram(sl).unwrap() {
14660 let (out, comp) = stack_b.on_stateless_message(a_prefix, &m).unwrap();
14661 b_final.extend(out);
14662 if let Some((id, sec)) = comp {
14663 b_remote_id = Some(id);
14664 b_secret = Some(sec);
14665 }
14666 }
14667 }
14668 let b_remote_id = b_remote_id.expect("B remote identity");
14669 let b_secret = b_secret.expect("B completes");
14670
14671 // B registers A's Kx, installs A's crypto token (from a_volatile).
14672 gate_b
14673 .register_remote_by_guid_from_secret(a_key_pk, b_remote_id, b_secret)
14674 .unwrap();
14675 // A's volatile crypto token is cross-vendor SEC_*-protected
14676 // (protect_volatile_outbound). B must decrypt the SEC_PREFIX/BODY/POSTFIX sequence
14677 // with A's Kx key to the inner DATA submessage before the
14678 // volatile_reader can process it — mirrors unprotect_volatile_
14679 // datagram im Live-Dispatch.
14680 let unprotect_vol_b = |bytes: &[u8]| -> Option<Vec<u8>> {
14681 let subs = walk_submessages(bytes);
14682 let prefix_pos = subs.iter().position(|(id, _, _)| *id == SMID_SEC_PREFIX)?;
14683 let postfix_idx = subs[prefix_pos..]
14684 .iter()
14685 .position(|(id, _, _)| *id == SMID_SEC_POSTFIX)
14686 .map(|i| prefix_pos + i)?;
14687 let (_, p_start, _) = subs[prefix_pos];
14688 let (_, q_start, q_total) = subs[postfix_idx];
14689 let data_submsg = gate_b
14690 .decode_kx_datawriter_from(&a_key_pk, &bytes[p_start..q_start + q_total])
14691 .ok()?;
14692 let mut out = Vec::with_capacity(bytes.len());
14693 out.extend_from_slice(&bytes[..20]);
14694 for (i, &(_, start, total)) in subs.iter().enumerate() {
14695 if i < prefix_pos || i > postfix_idx {
14696 out.extend_from_slice(&bytes[start..start + total]);
14697 } else if i == prefix_pos {
14698 out.extend_from_slice(&data_submsg);
14699 }
14700 }
14701 Some(out)
14702 };
14703 let mut b_installed = 0;
14704 for vol in &a_volatile {
14705 let vol_plain = unprotect_vol_b(vol).unwrap_or_else(|| vol.clone());
14706 let parsed = zerodds_rtps::datagram::decode_datagram(&vol_plain).unwrap();
14707 let vol_src = parsed.header.guid_prefix;
14708 for sub in parsed.submessages {
14709 if let zerodds_rtps::datagram::ParsedSubmessage::Data(d) = sub {
14710 if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
14711 for m in stack_b.volatile_reader.handle_data(vol_src, &d).unwrap() {
14712 if m.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS {
14713 // plaintext keymat (confidentiality was provided by the SEC_*
14714 // protection of the volatile DATA, decrypted above) —
14715 // install directly, no transform_kx_inbound.
14716 let token = m.message_data[0]
14717 .binary_property(CRYPTO_TOKEN_PROP)
14718 .unwrap();
14719 gate_b
14720 .set_remote_data_token_by_guid(&a_key_pk, token)
14721 .unwrap();
14722 b_installed += 1;
14723 }
14724 }
14725 }
14726 }
14727 }
14728 }
14729 assert!(b_installed >= 1, "B must install A's crypto token");
14730
14731 // B builds + sends its crypto token — plaintext keymat in the
14732 // ParticipantGenericMessage (cross-vendor: confidentiality via SEC_*
14733 // protection of the transporting volatile DATA, not via token-internal
14734 // Kx encryption).
14735 let b_data_token = gate_b.local_token().unwrap();
14736 let b_crypto_msg = ParticipantGenericMessage {
14737 message_identity: MessageIdentity {
14738 source_guid: b_guid,
14739 sequence_number: 1,
14740 },
14741 related_message_identity: MessageIdentity::default(),
14742 destination_participant_key: a_guid,
14743 destination_endpoint_key: [0; 16],
14744 source_endpoint_key: [0; 16],
14745 message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
14746 message_data: alloc::vec![
14747 DataHolder::new("DDS:Crypto:AES_GCM_GMAC")
14748 .with_binary_property(CRYPTO_TOKEN_PROP, b_data_token)
14749 ],
14750 };
14751 let b_volatile = stack_b.volatile_writer.write(&b_crypto_msg).unwrap();
14752 // SEC_* submessage protection with A's Kx key (mirrors protect_volatile_
14753 // datagram in the live path): B encrypts the DATA submessage, A's
14754 // dispatch decrypts it via unprotect_volatile_datagram.
14755 let protect_vol_b = |bytes: &[u8]| -> Vec<u8> {
14756 let subs = walk_submessages(bytes);
14757 if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
14758 return bytes.to_vec();
14759 }
14760 let mut out = Vec::with_capacity(bytes.len() + 64);
14761 out.extend_from_slice(&bytes[..20]);
14762 for (id, start, total) in subs {
14763 let submsg = &bytes[start..start + total];
14764 if id == SMID_DATA {
14765 out.extend_from_slice(
14766 &gate_b.encode_kx_datawriter_for(&a_key_pk, submsg).unwrap(),
14767 );
14768 } else {
14769 out.extend_from_slice(submsg);
14770 }
14771 }
14772 out
14773 };
14774 let b_vol_protected = protect_vol_b(&b_volatile[0].bytes);
14775
14776 // B's Final + B's Crypto-Token an A's Dispatch: A installiert B's
14777 // Data token (automatically via install_crypto_token).
14778 for f in &b_final {
14779 dispatch_security_builtin_datagram(&rt, &f.bytes, Duration::from_secs(1));
14780 }
14781 dispatch_security_builtin_datagram(&rt, &b_vol_protected, Duration::from_secs(1));
14782
14783 // --- Secured DATA in both directions ---
14784 let msg_ab = fake_rtps(a_prefix, b"[A->B secured payload]");
14785 let wire_ab = gate_a
14786 .transform_outbound_for(&b_key_pk, &msg_ab, ProtectionLevel::Encrypt)
14787 .unwrap();
14788 assert_eq!(
14789 gate_b.transform_inbound_from(&a_key_pk, &wire_ab).unwrap(),
14790 msg_ab,
14791 "A->B secured DATA must round-trip"
14792 );
14793 let msg_ba = fake_rtps(b_prefix, b"[B->A secured payload]");
14794 let wire_ba = gate_b
14795 .transform_outbound_for(&a_key_pk, &msg_ba, ProtectionLevel::Encrypt)
14796 .unwrap();
14797 assert_eq!(
14798 gate_a.transform_inbound_from(&b_key_pk, &wire_ba).unwrap(),
14799 msg_ba,
14800 "B->A secured DATA must round-trip (A's dispatch installed B's token)"
14801 );
14802
14803 rt.shutdown();
14804 }
14805
14806 #[test]
14807 fn c34c_enable_security_builtins_replays_known_peers() {
14808 // Order reversed: SPDP discovery first, plugin-
14809 // activation afterward. enable_security_builtins must catch up on already-
14810 // known peers. Plus: demux without a plugin (before enable)
14811 // is a no-op + does not panic.
14812 let rt = DcpsRuntime::start(
14813 76,
14814 GuidPrefix::from_bytes([0x76; 12]),
14815 RuntimeConfig::default(),
14816 )
14817 .expect("start");
14818
14819 // Demux without a plugin: silent no-op
14820 dispatch_security_builtin_datagram(&rt, &[0u8; 16], Duration::from_secs(1));
14821
14822 let remote = GuidPrefix::from_bytes([0x77; 12]);
14823 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
14824 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
14825 let dg = make_remote_spdp_beacon_with_flags(remote, flags);
14826 handle_spdp_datagram(&rt, &dg);
14827
14828 let stack = rt.enable_security_builtins(VendorId::ZERODDS);
14829 {
14830 let s = stack.lock().unwrap();
14831 assert_eq!(
14832 s.stateless_writer.reader_proxy_count(),
14833 1,
14834 "late plugin activation must catch up on known peers"
14835 );
14836 }
14837
14838 rt.shutdown();
14839 }
14840
14841 /// #29 regression: the earlier per-peer once-guard blocked late-matched
14842 /// user-endpoint tokens. `pending_endpoint_tokens` must, with already-sent
14843 /// builtin tokens, let through EXACTLY the new user token — not treat the whole
14844 /// peer as "done".
14845 #[cfg(feature = "security")]
14846 #[test]
14847 fn pending_endpoint_tokens_keeps_late_user_token_after_builtins_sent() {
14848 use zerodds_security::generic_message::ParticipantGenericMessage;
14849 // An early-sent builtin token (secure-SEDP) ...
14850 let builtin = ParticipantGenericMessage {
14851 source_endpoint_key: [0xff; 16],
14852 destination_endpoint_key: [0xfe; 16],
14853 ..Default::default()
14854 };
14855 // ... and a late-matched user-endpoint token.
14856 let user = ParticipantGenericMessage {
14857 source_endpoint_key: [0x03; 16],
14858 destination_endpoint_key: [0x04; 16],
14859 ..Default::default()
14860 };
14861 let mut sent = alloc::collections::BTreeSet::new();
14862 sent.insert(endpoint_token_key(&builtin));
14863
14864 let pending = pending_endpoint_tokens(vec![builtin.clone(), user.clone()], &sent);
14865
14866 assert_eq!(pending.len(), 1, "only the new user token may be pending");
14867 assert_eq!(
14868 pending[0].source_endpoint_key, user.source_endpoint_key,
14869 "the let-through token must be the user-endpoint token"
14870 );
14871 // Idempotency: after sending, nothing is pending anymore.
14872 let mut sent2 = sent.clone();
14873 sent2.insert(endpoint_token_key(&user));
14874 assert!(
14875 pending_endpoint_tokens(vec![builtin, user], &sent2).is_empty(),
14876 "already-sent tokens must not become pending again"
14877 );
14878 }
14879
14880 /// QT (#76, FOUNDATIONAL): a writer and reader of the SAME type whose
14881 /// TypeIdentifier is a *complete* hash (EquivalenceHashComplete) absent from
14882 /// any registry must still match and exchange data. Before the fix the
14883 /// runtime type-consistency check resolved against a fresh empty registry
14884 /// and rejected the match with TYPE_CONSISTENCY_ENFORCEMENT.
14885 #[test]
14886 fn qt_same_complete_type_identifier_matches_and_exchanges() {
14887 let rt = DcpsRuntime::start(
14888 60,
14889 GuidPrefix::from_bytes([0x60; 12]),
14890 RuntimeConfig::default(),
14891 )
14892 .expect("start");
14893 let complete = zerodds_types::TypeIdentifier::EquivalenceHashComplete(
14894 zerodds_types::type_identifier::EquivalenceHash([0xC0; 14]),
14895 );
14896 let mut w_cfg = qr_writer_cfg(
14897 "QtTopic",
14898 zerodds_qos::DurabilityKind::Volatile,
14899 alloc::vec![],
14900 zerodds_qos::LivelinessKind::Automatic,
14901 );
14902 w_cfg.type_identifier = complete.clone();
14903 let mut r_cfg = qr_reader_cfg(
14904 "QtTopic",
14905 zerodds_qos::DurabilityKind::Volatile,
14906 alloc::vec![],
14907 zerodds_qos::LivelinessKind::Automatic,
14908 );
14909 r_cfg.type_identifier = complete;
14910 let w = rt.register_user_writer(w_cfg).expect("writer");
14911 let (_r, rx) = rt.register_user_reader(r_cfg).expect("reader");
14912 rt.write_user_sample(w, b"complete-typed".to_vec())
14913 .expect("write");
14914 let s = rx
14915 .recv_timeout(core::time::Duration::from_millis(200))
14916 .expect("complete-TypeIdentifier writer+reader must match + exchange");
14917 match s {
14918 UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"complete-typed"),
14919 other => panic!("expected Alive, got {other:?}"),
14920 }
14921 rt.shutdown();
14922 }
14923
14924 // ===================================================================
14925 // QR-cluster (#77) — same-runtime QoS behavioral regression tests.
14926 // ===================================================================
14927
14928 fn qr_writer_cfg(
14929 topic: &str,
14930 durability: zerodds_qos::DurabilityKind,
14931 partition: Vec<String>,
14932 liveliness: zerodds_qos::LivelinessKind,
14933 ) -> UserWriterConfig {
14934 UserWriterConfig {
14935 topic_name: topic.into(),
14936 type_name: "QrType".into(),
14937 reliable: true,
14938 durability,
14939 deadline: zerodds_qos::DeadlineQosPolicy::default(),
14940 lifespan: zerodds_qos::LifespanQosPolicy::default(),
14941 liveliness: zerodds_qos::LivelinessQosPolicy {
14942 kind: liveliness,
14943 lease_duration: QosDuration::INFINITE,
14944 },
14945 ownership: zerodds_qos::OwnershipKind::Shared,
14946 ownership_strength: 0,
14947 partition,
14948 user_data: alloc::vec![],
14949 topic_data: alloc::vec![],
14950 group_data: alloc::vec![],
14951 type_identifier: zerodds_types::TypeIdentifier::None,
14952 data_representation_offer: None,
14953 }
14954 }
14955
14956 fn qr_reader_cfg(
14957 topic: &str,
14958 durability: zerodds_qos::DurabilityKind,
14959 partition: Vec<String>,
14960 liveliness: zerodds_qos::LivelinessKind,
14961 ) -> UserReaderConfig {
14962 UserReaderConfig {
14963 topic_name: topic.into(),
14964 type_name: "QrType".into(),
14965 reliable: true,
14966 durability,
14967 deadline: zerodds_qos::DeadlineQosPolicy::default(),
14968 liveliness: zerodds_qos::LivelinessQosPolicy {
14969 kind: liveliness,
14970 lease_duration: QosDuration::INFINITE,
14971 },
14972 ownership: zerodds_qos::OwnershipKind::Shared,
14973 partition,
14974 user_data: alloc::vec![],
14975 topic_data: alloc::vec![],
14976 group_data: alloc::vec![],
14977 type_identifier: zerodds_types::TypeIdentifier::None,
14978 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
14979 data_representation_offer: None,
14980 }
14981 }
14982
14983 /// QR (a) HISTORY KeepLast: a TransientLocal writer with depth=2 retains
14984 /// only the last 2 samples per instance; a late-joining reader replays
14985 /// exactly those 2 (not all 3 written).
14986 #[test]
14987 fn qr_history_keep_last_depth_enforced_on_replay() {
14988 let rt = DcpsRuntime::start(
14989 61,
14990 GuidPrefix::from_bytes([0x61; 12]),
14991 RuntimeConfig::default(),
14992 )
14993 .expect("start");
14994 let w = rt
14995 .register_user_writer(qr_writer_cfg(
14996 "QrHistory",
14997 zerodds_qos::DurabilityKind::TransientLocal,
14998 alloc::vec![],
14999 zerodds_qos::LivelinessKind::Automatic,
15000 ))
15001 .expect("writer");
15002 rt.set_user_writer_history_depth(w, 2).expect("set depth");
15003
15004 // Three writes BEFORE any reader exists.
15005 rt.write_user_sample(w, b"s1".to_vec()).expect("w1");
15006 rt.write_user_sample(w, b"s2".to_vec()).expect("w2");
15007 rt.write_user_sample(w, b"s3".to_vec()).expect("w3");
15008 assert_eq!(
15009 rt.user_writer_retained_len(w),
15010 2,
15011 "KeepLast(2) must retain only the 2 most recent samples"
15012 );
15013
15014 // Late-joining reader replays exactly the last 2 (s2, s3).
15015 let (_r, rx) = rt
15016 .register_user_reader(qr_reader_cfg(
15017 "QrHistory",
15018 zerodds_qos::DurabilityKind::TransientLocal,
15019 alloc::vec![],
15020 zerodds_qos::LivelinessKind::Automatic,
15021 ))
15022 .expect("reader");
15023
15024 let mut got: Vec<Vec<u8>> = Vec::new();
15025 while let Ok(s) = rx.recv_timeout(core::time::Duration::from_millis(200)) {
15026 if let UserSample::Alive { payload, .. } = s {
15027 got.push(payload.as_ref().to_vec());
15028 }
15029 if got.len() == 2 {
15030 break;
15031 }
15032 }
15033 assert_eq!(got, alloc::vec![b"s2".to_vec(), b"s3".to_vec()]);
15034 rt.shutdown();
15035 }
15036
15037 /// QR (b) DURABILITY TRANSIENT_LOCAL: a late-joining reader receives the
15038 /// retained sample written before it matched. A VOLATILE writer replays
15039 /// nothing.
15040 #[test]
15041 fn qr_transient_local_late_join_replay_vs_volatile() {
15042 // TransientLocal: late joiner sees the prior sample.
15043 let rt = DcpsRuntime::start(
15044 62,
15045 GuidPrefix::from_bytes([0x62; 12]),
15046 RuntimeConfig::default(),
15047 )
15048 .expect("start");
15049 let w = rt
15050 .register_user_writer(qr_writer_cfg(
15051 "QrTL",
15052 zerodds_qos::DurabilityKind::TransientLocal,
15053 alloc::vec![],
15054 zerodds_qos::LivelinessKind::Automatic,
15055 ))
15056 .expect("writer");
15057 rt.write_user_sample(w, b"retained".to_vec())
15058 .expect("write");
15059 let (_r, rx) = rt
15060 .register_user_reader(qr_reader_cfg(
15061 "QrTL",
15062 zerodds_qos::DurabilityKind::TransientLocal,
15063 alloc::vec![],
15064 zerodds_qos::LivelinessKind::Automatic,
15065 ))
15066 .expect("reader");
15067 let s = rx
15068 .recv_timeout(core::time::Duration::from_millis(200))
15069 .expect("TransientLocal late joiner must replay the retained sample");
15070 match s {
15071 UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"retained"),
15072 other => panic!("expected Alive, got {other:?}"),
15073 }
15074 rt.shutdown();
15075
15076 // Volatile: late joiner gets nothing for the pre-match write.
15077 let rt2 = DcpsRuntime::start(
15078 63,
15079 GuidPrefix::from_bytes([0x63; 12]),
15080 RuntimeConfig::default(),
15081 )
15082 .expect("start");
15083 let w2 = rt2
15084 .register_user_writer(qr_writer_cfg(
15085 "QrVol",
15086 zerodds_qos::DurabilityKind::Volatile,
15087 alloc::vec![],
15088 zerodds_qos::LivelinessKind::Automatic,
15089 ))
15090 .expect("writer");
15091 rt2.write_user_sample(w2, b"lost".to_vec()).expect("write");
15092 assert_eq!(
15093 rt2.user_writer_retained_len(w2),
15094 0,
15095 "Volatile retains nothing"
15096 );
15097 let (_r2, rx2) = rt2
15098 .register_user_reader(qr_reader_cfg(
15099 "QrVol",
15100 zerodds_qos::DurabilityKind::Volatile,
15101 alloc::vec![],
15102 zerodds_qos::LivelinessKind::Automatic,
15103 ))
15104 .expect("reader");
15105 assert!(
15106 rx2.recv_timeout(core::time::Duration::from_millis(120))
15107 .is_err(),
15108 "Volatile late joiner must NOT replay a pre-match sample"
15109 );
15110 rt2.shutdown();
15111 }
15112
15113 /// QR (c) PARTITION: a writer in partition ["A"] and a reader in ["B"]
15114 /// must NOT match (no intra-runtime route); a matching partition delivers.
15115 #[test]
15116 fn qr_partition_gates_intra_runtime_match() {
15117 let rt = DcpsRuntime::start(
15118 64,
15119 GuidPrefix::from_bytes([0x64; 12]),
15120 RuntimeConfig::default(),
15121 )
15122 .expect("start");
15123 // Mismatched partitions.
15124 let w = rt
15125 .register_user_writer(qr_writer_cfg(
15126 "QrPart",
15127 zerodds_qos::DurabilityKind::Volatile,
15128 alloc::vec!["A".into()],
15129 zerodds_qos::LivelinessKind::Automatic,
15130 ))
15131 .expect("writer");
15132 let (_r_mismatch, rx_mismatch) = rt
15133 .register_user_reader(qr_reader_cfg(
15134 "QrPart",
15135 zerodds_qos::DurabilityKind::Volatile,
15136 alloc::vec!["B".into()],
15137 zerodds_qos::LivelinessKind::Automatic,
15138 ))
15139 .expect("reader");
15140 rt.write_user_sample(w, b"x".to_vec()).expect("write");
15141 assert!(
15142 rx_mismatch
15143 .recv_timeout(core::time::Duration::from_millis(120))
15144 .is_err(),
15145 "partitions [A] vs [B] must not match"
15146 );
15147
15148 // Matching partition reader added → now delivers.
15149 let (_r_match, rx_match) = rt
15150 .register_user_reader(qr_reader_cfg(
15151 "QrPart",
15152 zerodds_qos::DurabilityKind::Volatile,
15153 alloc::vec!["A".into()],
15154 zerodds_qos::LivelinessKind::Automatic,
15155 ))
15156 .expect("reader");
15157 rt.write_user_sample(w, b"y".to_vec()).expect("write");
15158 let s = rx_match
15159 .recv_timeout(core::time::Duration::from_millis(200))
15160 .expect("partition [A] vs [A] must match");
15161 match s {
15162 UserSample::Alive { payload, .. } => assert_eq!(payload.as_ref(), b"y"),
15163 other => panic!("expected Alive, got {other:?}"),
15164 }
15165 rt.shutdown();
15166 }
15167
15168 /// QR (d) KEYED LIFECYCLE: dispose(key) delivers a Lifecycle marker to a
15169 /// matched same-runtime reader; a later late joiner observes the terminal
15170 /// NOT_ALIVE_DISPOSED state.
15171 #[test]
15172 fn qr_dispose_delivers_lifecycle_to_intra_reader() {
15173 use zerodds_rtps::history_cache::ChangeKind;
15174 use zerodds_rtps::inline_qos::status_info;
15175 let rt = DcpsRuntime::start(
15176 65,
15177 GuidPrefix::from_bytes([0x65; 12]),
15178 RuntimeConfig::default(),
15179 )
15180 .expect("start");
15181 let w = rt
15182 .register_user_writer_kind(
15183 qr_writer_cfg(
15184 "QrLifecycle",
15185 zerodds_qos::DurabilityKind::TransientLocal,
15186 alloc::vec![],
15187 zerodds_qos::LivelinessKind::Automatic,
15188 ),
15189 true,
15190 )
15191 .expect("writer");
15192 let (_r, rx) = rt
15193 .register_user_reader_kind(
15194 qr_reader_cfg(
15195 "QrLifecycle",
15196 zerodds_qos::DurabilityKind::TransientLocal,
15197 alloc::vec![],
15198 zerodds_qos::LivelinessKind::Automatic,
15199 ),
15200 true,
15201 )
15202 .expect("reader");
15203
15204 let key = [0xAB_u8; 16];
15205 rt.write_user_sample_keyed(w, b"alive", key).expect("write");
15206 // First the alive sample.
15207 let first = rx
15208 .recv_timeout(core::time::Duration::from_millis(200))
15209 .expect("alive sample");
15210 assert!(matches!(first, UserSample::Alive { .. }));
15211
15212 // dispose(key) → Lifecycle marker NOT_ALIVE_DISPOSED.
15213 rt.write_user_lifecycle(w, key, status_info::DISPOSED)
15214 .expect("dispose");
15215 let life = rx
15216 .recv_timeout(core::time::Duration::from_millis(200))
15217 .expect("dispose must deliver a Lifecycle marker to the matched reader");
15218 match life {
15219 UserSample::Lifecycle { key_hash, kind } => {
15220 assert_eq!(key_hash, key);
15221 assert_eq!(kind, ChangeKind::NotAliveDisposed);
15222 }
15223 other => panic!("expected Lifecycle, got {other:?}"),
15224 }
15225
15226 // A brand-new late joiner replays the alive sample AND the terminal
15227 // disposed marker, so it learns the instance is NOT_ALIVE_DISPOSED.
15228 let (_r2, rx2) = rt
15229 .register_user_reader_kind(
15230 qr_reader_cfg(
15231 "QrLifecycle",
15232 zerodds_qos::DurabilityKind::TransientLocal,
15233 alloc::vec![],
15234 zerodds_qos::LivelinessKind::Automatic,
15235 ),
15236 true,
15237 )
15238 .expect("reader2");
15239 let mut saw_disposed = false;
15240 while let Ok(s) = rx2.recv_timeout(core::time::Duration::from_millis(200)) {
15241 if let UserSample::Lifecycle { kind, .. } = s {
15242 if kind == ChangeKind::NotAliveDisposed {
15243 saw_disposed = true;
15244 break;
15245 }
15246 }
15247 }
15248 assert!(
15249 saw_disposed,
15250 "late joiner must observe the terminal NOT_ALIVE_DISPOSED state"
15251 );
15252 rt.shutdown();
15253 }
15254
15255 /// QR (d) KEYED LIFECYCLE — unregister(key) maps to NOT_ALIVE (NO_WRITERS).
15256 #[test]
15257 fn qr_unregister_delivers_no_writers_lifecycle() {
15258 use zerodds_rtps::history_cache::ChangeKind;
15259 use zerodds_rtps::inline_qos::status_info;
15260 let rt = DcpsRuntime::start(
15261 66,
15262 GuidPrefix::from_bytes([0x66; 12]),
15263 RuntimeConfig::default(),
15264 )
15265 .expect("start");
15266 let w = rt
15267 .register_user_writer_kind(
15268 qr_writer_cfg(
15269 "QrUnreg",
15270 zerodds_qos::DurabilityKind::Volatile,
15271 alloc::vec![],
15272 zerodds_qos::LivelinessKind::Automatic,
15273 ),
15274 true,
15275 )
15276 .expect("writer");
15277 let (_r, rx) = rt
15278 .register_user_reader_kind(
15279 qr_reader_cfg(
15280 "QrUnreg",
15281 zerodds_qos::DurabilityKind::Volatile,
15282 alloc::vec![],
15283 zerodds_qos::LivelinessKind::Automatic,
15284 ),
15285 true,
15286 )
15287 .expect("reader");
15288 let key = [0x11_u8; 16];
15289 rt.write_user_lifecycle(w, key, status_info::UNREGISTERED)
15290 .expect("unregister");
15291 let life = rx
15292 .recv_timeout(core::time::Duration::from_millis(200))
15293 .expect("unregister must deliver a Lifecycle marker");
15294 match life {
15295 UserSample::Lifecycle { key_hash, kind } => {
15296 assert_eq!(key_hash, key);
15297 assert_eq!(kind, ChangeKind::NotAliveUnregistered);
15298 }
15299 other => panic!("expected Lifecycle, got {other:?}"),
15300 }
15301 rt.shutdown();
15302 }
15303
15304 /// QR (e) LIVELINESS AUTOMATIC: the reader's liveliness_changed alive_count
15305 /// tracks a live matched writer on the same-runtime path.
15306 #[test]
15307 fn qr_liveliness_automatic_bumps_reader_alive_count() {
15308 let rt = DcpsRuntime::start(
15309 67,
15310 GuidPrefix::from_bytes([0x67; 12]),
15311 RuntimeConfig::default(),
15312 )
15313 .expect("start");
15314 let w = rt
15315 .register_user_writer(qr_writer_cfg(
15316 "QrLive",
15317 zerodds_qos::DurabilityKind::Volatile,
15318 alloc::vec![],
15319 zerodds_qos::LivelinessKind::Automatic,
15320 ))
15321 .expect("writer");
15322 let (r, rx) = rt
15323 .register_user_reader(qr_reader_cfg(
15324 "QrLive",
15325 zerodds_qos::DurabilityKind::Volatile,
15326 alloc::vec![],
15327 zerodds_qos::LivelinessKind::Automatic,
15328 ))
15329 .expect("reader");
15330
15331 let (_alive0, count0, _na0) = rt.user_reader_liveliness_status(r);
15332 assert_eq!(count0, 0, "no writer has delivered yet");
15333
15334 rt.write_user_sample(w, b"beat".to_vec()).expect("write");
15335 let _ = rx.recv_timeout(core::time::Duration::from_millis(200));
15336
15337 let (alive, count, _na) = rt.user_reader_liveliness_status(r);
15338 assert!(alive, "AUTOMATIC writer keeps the reader's match alive");
15339 assert_eq!(
15340 count, 1,
15341 "alive_count must bump to 1 for the live matched AUTOMATIC writer"
15342 );
15343
15344 // A second write from the same writer does NOT double-count.
15345 rt.write_user_sample(w, b"beat2".to_vec()).expect("write");
15346 let _ = rx.recv_timeout(core::time::Duration::from_millis(200));
15347 let (_a, count2, _n) = rt.user_reader_liveliness_status(r);
15348 assert_eq!(count2, 1, "same writer must not bump alive_count twice");
15349 rt.shutdown();
15350 }
15351}