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 "").
109fn partitions_overlap(offered: &[String], requested: &[String]) -> bool {
110 if offered.is_empty() && requested.is_empty() {
111 return true;
112 }
113 // An empty list is treated as ["" (default)].
114 let off_default = offered.is_empty();
115 let req_default = requested.is_empty();
116 if off_default && requested.iter().any(|s| s.is_empty()) {
117 return true;
118 }
119 if req_default && offered.iter().any(|s| s.is_empty()) {
120 return true;
121 }
122 // Both non-default: intersect.
123 offered.iter().any(|o| requested.iter().any(|r| r == o))
124}
125
126/// Materializes the locator address that we announce in the SPDP beacon
127/// from an UdpTransport bound to UNSPECIFIED.
128///
129/// Binding to `0.0.0.0` yields `local_addr() == 0.0.0.0:port`, which is
130/// not routable for peers. Via a UDP connect probe to a non-routable
131/// address we resolve the outbound interface address (no traffic —
132/// `connect()` on a UDP socket only sets the routing information). Falls
133/// back to `multicast_interface` (RuntimeConfig) if the probe fails, or
134/// to the unchanged locator as a last resort.
135#[cfg(feature = "std")]
136fn announce_locator(uc: &(dyn Transport + Send + Sync), hint: Ipv4Addr) -> Locator {
137 let raw = uc.local_locator();
138 // Keep the port from the bound socket.
139 let port = raw.port;
140 // V6 resolution: with a `::` bind, announce `::1` (loopback) as a
141 // sensible default reachability. Cross-host v6 is its own sprint
142 // (needs a v6 interface probe analogous to the v4 path below).
143 if raw.kind == LocatorKind::UdpV6 || raw.kind == LocatorKind::Tcpv6 {
144 let all_zero = raw.address.iter().all(|b| *b == 0);
145 if all_zero {
146 let mut loopback_addr = [0u8; 16];
147 loopback_addr[15] = 1;
148 return match raw.kind {
149 LocatorKind::Tcpv6 => Locator::tcp_v6(loopback_addr, port),
150 _ => Locator::udp_v6(loopback_addr, port),
151 };
152 }
153 return raw;
154 }
155 // V4 resolution: only meaningful for UDPv4/TCPv4 locators with an
156 // UNSPECIFIED bind. For SHM, return raw — the locator kind has its
157 // own pairing resolution (its own sprint).
158 if raw.kind != LocatorKind::UdpV4 && raw.kind != LocatorKind::Tcpv4 {
159 return raw;
160 }
161 // Extract the address — only the last 4 bytes are the IPv4.
162 let ip = Ipv4Addr::new(
163 raw.address[12],
164 raw.address[13],
165 raw.address[14],
166 raw.address[15],
167 );
168 if !ip.is_unspecified() {
169 return raw;
170 }
171 // Helper: construct a locator with the original kind (UdpV4 or
172 // Tcpv4) and the now-resolved v4 address.
173 let to_locator = |octets: [u8; 4]| -> Locator {
174 match raw.kind {
175 LocatorKind::Tcpv4 => Locator::tcp_v4(octets, port),
176 _ => Locator::udp_v4(octets, port),
177 }
178 };
179 // Interface pinning: an explicitly set interface
180 // (`ZERODDS_INTERFACE` / `RuntimeConfig.multicast_interface`) takes
181 // **precedence over the route probe**. On multi-homed hosts (VPN/
182 // Docker/macOS bridge100) the probe might otherwise pick the wrong
183 // source IP and announce an unreachable address → discovery fails.
184 if !hint.is_unspecified() {
185 return to_locator(hint.octets());
186 }
187 // Probe: temporary socket, "connect" to 192.0.2.1 (RFC 5737
188 // TEST-NET-1, guaranteed non-routable). connect only sets the routing
189 // table — no packet goes out.
190 if let Ok(probe) =
191 std::net::UdpSocket::bind(std::net::SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0))
192 {
193 if probe
194 .connect(std::net::SocketAddrV4::new(Ipv4Addr::new(192, 0, 2, 1), 7))
195 .is_ok()
196 {
197 if let Ok(std::net::SocketAddr::V4(local)) = probe.local_addr() {
198 let resolved = local.ip();
199 if !resolved.is_unspecified() {
200 return to_locator(resolved.octets());
201 }
202 }
203 }
204 }
205 // Fallback: loopback (the pin hint is already handled above). Not
206 // ideal, but better than 0.0.0.0 as a locator (at least routable on
207 // the same host).
208 to_locator([127, 0, 0, 1])
209}
210
211/// Converts a `core::time::Duration` (std) to a `zerodds_qos::Duration`
212/// (spec 2^-32 fraction encoding). Saturates on overflow — `i32::MAX`
213/// seconds suffices for over 60 years of lease.
214fn qos_duration_from_std(d: Duration) -> QosDuration {
215 let secs = i32::try_from(d.as_secs()).unwrap_or(i32::MAX);
216 let nanos = d.subsec_nanos();
217 // The spec fraction is 2^-32 s; from nanos back via (nanos << 32) / 1e9.
218 let fraction = ((u64::from(nanos)) << 32) / 1_000_000_000u64;
219 QosDuration {
220 seconds: secs,
221 fraction: fraction as u32,
222 }
223}
224
225/// Converts a `zerodds_qos::Duration` to nanoseconds (0 = INFINITE,
226/// "no monitoring"). `seconds` is i32 — we clamp to non-negative.
227fn qos_duration_to_nanos(d: zerodds_qos::Duration) -> u64 {
228 if d.is_infinite() {
229 return 0;
230 }
231 let secs = d.seconds.max(0) as u64;
232 // fraction is 2^-32 s, i.e. nanos = fraction * 1e9 / 2^32.
233 let frac_nanos = ((d.fraction as u64) * 1_000_000_000u64) >> 32;
234 secs.saturating_mul(1_000_000_000u64)
235 .saturating_add(frac_nanos)
236}
237
238/// Human-readable name of a QoS policy id (Spec OMG DDS 1.4 §2.2.3,
239/// PSM ids from [`crate::psm_constants::qos_policy_id`]). Used for the
240/// C2 "loud instead of silent" log on an incompatible QoS match, so that
241/// it states in plain text *which* policy prevented the match.
242#[must_use]
243fn qos_policy_id_name(pid: u32) -> &'static str {
244 use crate::psm_constants::qos_policy_id as qid;
245 match pid {
246 qid::DURABILITY => "DURABILITY",
247 qid::PRESENTATION => "PRESENTATION",
248 qid::DEADLINE => "DEADLINE",
249 qid::LATENCY_BUDGET => "LATENCY_BUDGET",
250 qid::OWNERSHIP => "OWNERSHIP",
251 qid::OWNERSHIP_STRENGTH => "OWNERSHIP_STRENGTH",
252 qid::LIVELINESS => "LIVELINESS",
253 qid::PARTITION => "PARTITION",
254 qid::RELIABILITY => "RELIABILITY",
255 qid::DESTINATION_ORDER => "DESTINATION_ORDER",
256 qid::DURABILITY_SERVICE => "DURABILITY_SERVICE",
257 qid::TYPE_CONSISTENCY_ENFORCEMENT => "TYPE_CONSISTENCY_ENFORCEMENT",
258 qid::DATA_REPRESENTATION => "DATA_REPRESENTATION",
259 _ => "OTHER",
260 }
261}
262
263/// RTPS serialized-payload header for user samples: `CDR_LE`
264/// (PLAIN_CDR / XCDR1, little-endian) + options=0. Spec OMG RTPS 2.5
265/// §9.4.2.13.
266///
267/// Prepended to every user payload before it goes into the DATA
268/// submessage — without this header, vendor readers (Cyclone / Fast-DDS)
269/// refuse to deliver the sample.
270///
271/// **Why `0x01` (XCDR1) and not `0x07` (XCDR2):** the C++ PSM codegen
272/// (`dds/topic/xcdr2.hpp`) aligns 8-byte primitives to `sizeof` — that
273/// is the PLAIN_CDR/XCDR1 rule, NOT XCDR2 (which requires
274/// `min(sizeof,4)`). ZeroDDS therefore effectively produces an XCDR1
275/// layout; the encapsulation header must declare that honestly,
276/// otherwise the peer reads the body with the wrong alignment (e.g.
277/// OpenDDS' `dds_demarshal` fails). Full XCDR2 support is a separate
278/// codegen feature.
279pub const USER_PAYLOAD_ENCAP: [u8; 4] = [0x00, 0x01, 0x00, 0x00];
280
281/// Encapsulation header for the user payload, based on the negotiated
282/// DataRepresentation (`offer_first`: the **first** element of the
283/// writer's offer list = the wire format actually emitted by the writer)
284/// and the type extensibility. The header MUST honestly declare the body
285/// encoding produced by the codegen, otherwise the peer (e.g.
286/// FastDDS/OpenDDS XCDR2-only reader) reads the body with the wrong
287/// alignment or wrongly expects a DHEADER.
288///
289/// DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 (little-endian variant):
290/// XCDR1 final/appendable -> CDR_LE `0x0001`
291/// XCDR1 mutable -> PL_CDR_LE `0x0003`
292/// XCDR2 final -> PLAIN_CDR2_LE `0x0007`
293/// XCDR2 appendable -> D_CDR2_LE `0x0009`
294/// XCDR2 mutable -> PL_CDR2_LE `0x000b`
295#[must_use]
296fn user_payload_encap(offer_first: i16, ext: zerodds_types::qos::ExtensibilityForRepr) -> [u8; 4] {
297 use zerodds_rtps::publication_data::data_representation as dr;
298 use zerodds_types::qos::ExtensibilityForRepr::{Appendable, Final, Mutable};
299 let id: u8 = match (offer_first, ext) {
300 (dr::XCDR2, Final) => 0x07,
301 (dr::XCDR2, Appendable) => 0x09,
302 (dr::XCDR2, Mutable) => 0x0b,
303 // XCDR1: appendable is treated like final (Tab.59: XCDR1 has no
304 // dedicated APPENDABLE encoding).
305 (dr::XCDR, Mutable) => 0x03,
306 // (dr::XCDR, Final|Appendable) as well as XML/unknown -> CDR_LE.
307 _ => 0x01,
308 };
309 [0x00, id, 0x00, 0x00]
310}
311
312/// Stack PoolBuffer cap for the small-sample path in
313/// [`DcpsRuntime::write_user_sample`]. A 1.5 KiB payload + 4 B encap
314/// header fit through the framing without touching the heap.
315const SMALL_FRAME_CAP: usize = 1536;
316
317/// Small-sample hot-path helper: frames `USER_PAYLOAD_ENCAP` + payload
318/// into a stack `PoolBuffer<SMALL_FRAME_CAP>` and hands the slice to the
319/// writer. No Vec/Box/Rc/Arc allocation in this function — verified by
320/// the `dds_no_realloc_in_hot_path` lint.
321///
322/// zerodds-lint: hot-path-realloc-free
323fn write_user_sample_pooled(
324 writer: &mut ReliableWriter,
325 payload: &[u8],
326 now: Duration,
327 encap: &[u8; 4],
328) -> Result<Vec<zerodds_rtps::message_builder::OutboundDatagram>> {
329 let mut frame = zerodds_foundation::PoolBuffer::<SMALL_FRAME_CAP>::new();
330 frame
331 .extend_from_slice(encap)
332 .map_err(|_| DdsError::WireError {
333 message: String::from("user encap framing"),
334 })?;
335 frame
336 .extend_from_slice(payload)
337 .map_err(|_| DdsError::WireError {
338 message: String::from("user payload framing"),
339 })?;
340 // Hot path: only DATA, NO HEARTBEAT. Cyclone DDS rate-limits the
341 // HB piggyback (≥100 µs spacing, or a packet boundary) — so it does
342 // NOT send an HB per write. At 14k writes/sec this would fire 14k
343 // unnecessary submessages and (with an unaligned payload) 14k extra
344 // sendto syscalls. Periodic HBs are handled by the tick loop (every
345 // `heartbeat_period` ms, default 100 ms); we no longer attach `_now`
346 // to `last_heartbeat`, because we emit nothing.
347 let _ = now;
348 writer
349 .write(frame.as_slice())
350 .map_err(|_| DdsError::WireError {
351 message: String::from("user writer encode"),
352 })
353}
354
355/// Choice of transport for DCPS user traffic. Discovery (SPDP/SEDP)
356/// remains UDPv4 multicast independently of this.
357#[derive(Debug, Clone, Copy, PartialEq, Eq)]
358#[non_exhaustive]
359pub enum UserTransportKind {
360 /// UDP IPv4 (default).
361 UdpV4,
362 /// UDP IPv6.
363 UdpV6,
364 /// TCP IPv4 (DDS-TCP-PSM `LOCATOR_KIND_TCPV4`).
365 TcpV4,
366 /// TCP IPv6 (DDS-TCP-PSM `LOCATOR_KIND_TCPV6`).
367 TcpV6,
368 /// POSIX shared memory (same-host). Only with the `same-host-shm`
369 /// feature.
370 #[cfg(feature = "same-host-shm")]
371 Shm,
372 /// Unix domain socket (same-host, container-friendly). Only with the
373 /// `same-host-uds` feature.
374 #[cfg(feature = "same-host-uds")]
375 Uds,
376 /// TSN L2 transport (AF_PACKET, RTPS direct on Ethernet, EtherType
377 /// 0x88B5). Only with the `tsn-live` feature on Linux. Interface/VLAN/
378 /// PCP via the env vars `ZERODDS_TSN_IFACE`/`_VLAN`/`_PCP`.
379 #[cfg(all(feature = "tsn-live", target_os = "linux"))]
380 Tsn,
381}
382
383/// Maps the `ZERODDS_USER_TRANSPORT` env var to a [`UserTransportKind`].
384/// `None` if unset or unknown — the caller then falls back to UDPv4.
385fn parse_user_transport_env() -> Option<UserTransportKind> {
386 match std::env::var("ZERODDS_USER_TRANSPORT").ok()?.as_str() {
387 "UDPv4" => Some(UserTransportKind::UdpV4),
388 "UDPv6" => Some(UserTransportKind::UdpV6),
389 "TCPv4" => Some(UserTransportKind::TcpV4),
390 "TCPv6" => Some(UserTransportKind::TcpV6),
391 #[cfg(feature = "same-host-shm")]
392 "SHM" => Some(UserTransportKind::Shm),
393 #[cfg(feature = "same-host-uds")]
394 "UDS" => Some(UserTransportKind::Uds),
395 #[cfg(all(feature = "tsn-live", target_os = "linux"))]
396 "TSN" => Some(UserTransportKind::Tsn),
397 _ => None,
398 }
399}
400
401/// Result of [`select_user_transport`]: the user-traffic transport plus
402/// an optional `TcpTransport` accept handle (only for TCP).
403type UserTransportSelection = (
404 Arc<dyn Transport + Send + Sync>,
405 Option<Arc<zerodds_transport_tcp::TcpTransport>>,
406);
407
408/// Binds the user-traffic transport for the selected
409/// [`UserTransportKind`]. Discovery (SPDP/SEDP) runs separately over
410/// UDPv4 multicast; this transport carries only the DCPS user traffic.
411///
412/// Additionally returns an optional `TcpTransport` accept handle: TCP has
413/// no implicit accept thread in the constructor, so the caller starts an
414/// `accept_one` worker for it.
415#[cfg_attr(
416 not(any(feature = "same-host-shm", feature = "same-host-uds")),
417 allow(unused_variables)
418)]
419fn select_user_transport(
420 kind: UserTransportKind,
421 guid_prefix: GuidPrefix,
422 domain_id: i32,
423 pinned: Ipv4Addr,
424) -> Result<UserTransportSelection> {
425 match kind {
426 UserTransportKind::UdpV4 => {
427 // Interface pinning: bind to the pinned IPv4 (egress + receive
428 // on exactly this interface), otherwise `0.0.0.0` (auto).
429 let udp = UdpTransport::bind_v4(pinned, 0)
430 .map_err(|_| DdsError::TransportError {
431 label: "user unicast bind (UDPv4)",
432 })?
433 .with_timeout(Some(Duration::from_secs(1)))
434 .map_err(|_| DdsError::TransportError {
435 label: "user unicast set_timeout (UDPv4)",
436 })?;
437 Ok((Arc::new(udp), None))
438 }
439 UserTransportKind::UdpV6 => {
440 let udp = UdpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
441 .map_err(|_| DdsError::TransportError {
442 label: "user unicast bind (UDPv6)",
443 })?
444 .with_timeout(Some(Duration::from_secs(1)))
445 .map_err(|_| DdsError::TransportError {
446 label: "user unicast set_timeout (UDPv6)",
447 })?;
448 Ok((Arc::new(udp), None))
449 }
450 UserTransportKind::TcpV4 => {
451 // Interface pinning analogous to UDPv4.
452 let tcp = zerodds_transport_tcp::TcpTransport::bind_v4(pinned, 0).map_err(|_| {
453 DdsError::TransportError {
454 label: "user unicast bind (TCPv4)",
455 }
456 })?;
457 let arc = Arc::new(tcp);
458 let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
459 Ok((dynamic, Some(arc)))
460 }
461 UserTransportKind::TcpV6 => {
462 let tcp =
463 zerodds_transport_tcp::TcpTransport::bind_v6(std::net::Ipv6Addr::UNSPECIFIED, 0)
464 .map_err(|_| DdsError::TransportError {
465 label: "user unicast bind (TCPv6)",
466 })?;
467 let arc = Arc::new(tcp);
468 let dynamic: Arc<dyn Transport + Send + Sync> = arc.clone();
469 Ok((dynamic, Some(arc)))
470 }
471 #[cfg(feature = "same-host-shm")]
472 UserTransportKind::Shm => {
473 // local_id = guid_prefix (12 bytes) + 4-byte domain id so that
474 // separate domains get separate segments (no cross-domain
475 // collisions).
476 let mut local_id = [0u8; 16];
477 local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
478 local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
479 let shm = crate::shm_user::ShmUserTransport::new(
480 local_id,
481 zerodds_transport_shm::posix::ShmConfig::default(),
482 );
483 Ok((Arc::new(shm), None))
484 }
485 #[cfg(feature = "same-host-uds")]
486 UserTransportKind::Uds => {
487 // local_id = guid_prefix (12 bytes) + 4-byte domain id — the
488 // peer resolves this id from the announced UDS locator into the
489 // same socket path.
490 let mut local_id = [0u8; 16];
491 local_id[..12].copy_from_slice(&guid_prefix.to_bytes());
492 local_id[12..].copy_from_slice(&(domain_id as u32).to_be_bytes());
493 // recv_timeout analogous to the UDP path: the recv loop must
494 // periodically check the stop flag (otherwise a thread hang on
495 // shutdown on a blocking recv).
496 let uds_cfg = zerodds_transport_uds::UdsConfig {
497 recv_timeout: Some(Duration::from_secs(1)),
498 ..zerodds_transport_uds::UdsConfig::default()
499 };
500 let uds =
501 zerodds_transport_uds::UdsTransport::bind(local_id, uds_cfg).map_err(|_| {
502 DdsError::TransportError {
503 label: "user unicast bind (UDS)",
504 }
505 })?;
506 Ok((Arc::new(uds), None))
507 }
508 #[cfg(all(feature = "tsn-live", target_os = "linux"))]
509 UserTransportKind::Tsn => {
510 // Interface/VLAN/PCP via env (TSN needs a concrete interface;
511 // not bindable to 0.0.0.0 like UDP/TCP). recv_timeout 1s
512 // analogous to UDP for the stop-flag check.
513 let iface =
514 std::env::var("ZERODDS_TSN_IFACE").map_err(|_| DdsError::TransportError {
515 label: "ZERODDS_TSN_IFACE not set (TSN transport)",
516 })?;
517 let vlan = std::env::var("ZERODDS_TSN_VLAN")
518 .ok()
519 .and_then(|s| s.parse::<u16>().ok())
520 .unwrap_or(0);
521 let pcp = std::env::var("ZERODDS_TSN_PCP")
522 .ok()
523 .and_then(|s| s.parse::<u8>().ok())
524 .unwrap_or(0);
525 let tsn = zerodds_transport_tsn::socket::TsnTransport::bind(
526 &iface,
527 vlan,
528 pcp,
529 Some(Duration::from_secs(1)),
530 )
531 .map_err(|_| DdsError::TransportError {
532 label: "user unicast bind (TSN)",
533 })?;
534 Ok((Arc::new(tsn), None))
535 }
536 }
537}
538
539/// Configuration for the runtime. Exposed via DomainParticipant factory
540/// methods.
541#[derive(Clone)]
542pub struct RuntimeConfig {
543 /// Tick period of the event loop. Default 50 ms.
544 pub tick_period: Duration,
545 /// SPDP announce period. Default 5 s.
546 pub spdp_period: Duration,
547 /// C3 WiFi-robust discovery — number of initial SPDP announces sent at the
548 /// fast [`Self::initial_announce_period`] cadence (instead of `spdp_period`)
549 /// while no peer is yet discovered. Default
550 /// [`DEFAULT_INITIAL_ANNOUNCE_COUNT`]. `0` disables the burst (legacy
551 /// single-announce-then-`spdp_period` behaviour).
552 pub initial_announce_count: u32,
553 /// Period between initial-announcement-burst SPDP sends. Default
554 /// [`DEFAULT_INITIAL_ANNOUNCE_PERIOD`].
555 pub initial_announce_period: Duration,
556 /// SPDP multicast group (IPv4). Default 239.255.0.1 (Spec §9.6.1.4.1).
557 pub spdp_multicast_group: Ipv4Addr,
558 /// Interface address for the multicast join. Default 0.0.0.0 (the
559 /// kernel picks the default interface).
560 pub multicast_interface: Ipv4Addr,
561
562 /// C1: whether SPDP beacons are sent via multicast. Default `true`
563 /// (spec behavior). `false` (env `ZERODDS_NO_MULTICAST`) → pure
564 /// unicast discovery via [`Self::initial_peers`], not a single
565 /// multicast packet — for networks that drop multicast (WiFi/cloud
566 /// VPC), and for a rigorous multicast-free discovery proof.
567 pub spdp_multicast_send: bool,
568
569 /// C3: max reassemblable sample size (DoS cap of the fragment
570 /// assembler). Larger samples are silently discarded. The rtps
571 /// default was 1 MiB (the phase-1 assumption "large images = no
572 /// use case") — too small for ROS PointCloud2/Image (often several
573 /// MB). Default here 16 MiB; env `ZERODDS_MAX_SAMPLE_BYTES` (bytes)
574 /// overrides. Still a deliberate DoS guard, just ROS-realistic.
575 pub max_reassembly_sample_bytes: usize,
576
577 /// C1 multicast-free discovery: unicast initial-peer locators to
578 /// which SPDP beacons are sent **in addition** to multicast. Default
579 /// empty (= pure multicast behavior as before). Populated via
580 /// [`RuntimeConfig::default`] from the env `ZERODDS_PEERS` (comma
581 /// list of `ip` or `ip:port`). An `ip` without a port is expanded to
582 /// the well-known SPDP unicast ports of participant indices 0..N
583 /// (see [`expand_initial_peer`]).
584 pub initial_peers: Vec<Locator>,
585
586 /// Transport for DCPS user traffic. `None` (default) → fall back to
587 /// the env var `ZERODDS_USER_TRANSPORT`, otherwise UDPv4. Discovery
588 /// (SPDP/SEDP) remains UDPv4 multicast independently of this. Ignored when
589 /// [`Self::user_transports`] is non-empty.
590 pub user_transport: Option<UserTransportKind>,
591
592 /// Preference-ordered set of transports for DCPS user traffic. When
593 /// non-empty, the runtime builds a [`LayeredUserTransport`](crate::layered_transport::LayeredUserTransport)
594 /// over all of them: each datagram is routed to the first transport whose
595 /// locator kind matches the destination (so list the fast/local transport
596 /// first — e.g. `[Shm, UdpV4]` — and the fallback last), and receives are
597 /// multiplexed from all of them. Empty (default) → single-transport via
598 /// [`Self::user_transport`].
599 pub user_transports: alloc::vec::Vec<UserTransportKind>,
600
601 /// Optional security gate. Active only with the `security` feature.
602 /// When set, UDP outbound messages are pulled through
603 /// [`SharedSecurityGate::transform_outbound`], and inbound messages
604 /// through [`SharedSecurityGate::transform_inbound_from`] (peer key
605 /// from RTPS header bytes 8..20).
606 #[cfg(feature = "security")]
607 pub security: Option<std::sync::Arc<zerodds_security_runtime::SharedSecurityGate>>,
608 /// Optional LoggingPlugin for security events. Called by the inbound
609 /// path when packets are dropped due to a policy violation, tampering
610 /// or a legacy block.
611 #[cfg(feature = "security")]
612 pub security_logger: Option<std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin>>,
613
614 /// Multi-interface bindings. Empty → `user_unicast` is the only
615 /// outbound socket (legacy behavior). Non-empty →
616 /// `DcpsRuntime::start` builds a dedicated UDP socket per spec and the
617 /// writer tick loop routes to the matching socket per destination
618 /// locator.
619 #[cfg(feature = "security")]
620 pub interface_bindings: Vec<InterfaceBindingSpec>,
621
622 /// `true` → the SPDP beacon additionally announces the 12 secure
623 /// discovery bits (16..27, DDS-Security 1.2 §7.4.7.1). Default
624 /// `false` — only standard bits are announced. Set by the DCPS
625 /// factory once a PolicyEngine is configured. This flag is available
626 /// even without the `security` feature, so that tests can check bit
627 /// presence without activating the whole crypto crate.
628 pub announce_secure_endpoints: bool,
629
630 /// FastDDS interop: run the reliable secure SPDP channel (0xff0101c2/c7,
631 /// `ENTITYID_SPDP_RELIABLE_BUILTIN_PARTICIPANT_SECURE_*`). FastDDS announces
632 /// its full secured participant data (identity_token/security_info) over
633 /// this channel and gates the crypto-token reciprocation/endpoint matching
634 /// on it; cyclone does NOT need it (cyclone↔zerodds runs without). Default off
635 /// — enable only for FastDDS cross-vendor.
636 pub enable_secure_spdp: bool,
637
638 /// WLP-Tick-Periode (Writer-Liveliness-Protocol, RTPS 2.5 §8.4.13).
639 /// `Duration::ZERO` → default `participant_lease_duration / 3`
640 /// (spec recommendation: three misses before the reader marks the
641 /// writer as not-alive). A direct override enables aggressive
642 /// tests.
643 pub wlp_period: Duration,
644
645 /// Lease duration announced in the SPDP beacon as
646 /// `PARTICIPANT_LEASE_DURATION` (spec default 100 s). Also used as the
647 /// basis for the AUTOMATIC WLP tick (`wlp_period =
648 /// participant_lease_duration / 3` if `wlp_period == Duration::ZERO`).
649 pub participant_lease_duration: Duration,
650
651 /// USER_DATA bytes of the participant (DDS 1.4 §2.2.3.1
652 /// `UserDataQosPolicy`). Announced in the SPDP beacon as PID_USER_DATA
653 /// (DDSI-RTPS §9.6.3.2) and exposed on the receiver side in
654 /// `ParticipantBuiltinTopicData.user_data`. Default empty.
655 pub user_data: Vec<u8>,
656
657 /// Observability sink. Default is `null_sink()` — each event emit is
658 /// then a direct return without allocation on the consumer side.
659 /// Consumers inject e.g.
660 /// [`zerodds_foundation::observability::StderrJsonSink`] (JSON lines
661 /// for Vector/fluentd/Datadog) or their own OTLP bridge.
662 pub observability: zerodds_foundation::observability::SharedSink,
663
664 /// Sprint D.5d lever C — RT pinning + priority. Linux-only; on
665 /// macOS/Windows the hooks are no-ops.
666 ///
667 /// SCHED_FIFO priority (1-99) for the three recv workers (SPDP MC,
668 /// metatraffic, user data). `None` = default scheduler (CFS).
669 /// `Some(80)` is the spec recommendation for real-time paths. Requires
670 /// `CAP_SYS_NICE` or an `RLIMIT_RTPRIO`-permitted user.
671 pub recv_thread_priority: Option<i32>,
672
673 /// Like [`Self::recv_thread_priority`], but for the tick worker.
674 pub tick_thread_priority: Option<i32>,
675
676 /// CPU affinity mask for the recv workers. `None` = no affinity (the
677 /// kernel schedules freely). A list of CPU indices, e.g.
678 /// `vec![2, 3]` for cores 2+3. Set via `sched_setaffinity`; all three
679 /// recv threads share the same mask.
680 pub recv_thread_cpus: Option<Vec<usize>>,
681
682 /// Like [`Self::recv_thread_cpus`], but for the tick worker.
683 pub tick_thread_cpus: Option<Vec<usize>>,
684
685 /// Opt-3 (Spec `zerodds-zero-copy-1.0` §9): number of additional
686 /// user-data recv workers that listen on the same port as
687 /// `user_unicast` via `SO_REUSEPORT`. `0` (default) = only the primary
688 /// `recv_user_data_loop` worker. Under high recv load the pool scales
689 /// linearly with cores (kernel flow hashing distributes incoming
690 /// datagrams). Recommended values: 1-3 additional workers per CPU
691 /// core.
692 pub extra_recv_threads: usize,
693
694 /// D.5g — default DataRepresentation list announced in SEDP
695 /// PublicationData and SEDP SubscriptionData, when not overridden
696 /// per-writer/reader (UserWriterConfig/UserReaderConfig).
697 ///
698 /// **Important**: per strict spec (XTypes 1.3 §7.6.3.1.2) the first
699 /// element is the writer's "offered" and must be in the reader's
700 /// "accepted" list for a match to happen. Default `[XCDR1, XCDR2]` =
701 /// legacy-first → max interop with the RTI Connext Shapes Demo
702 /// (XCDR1-only). Pure-XCDR2 deployments can switch this to `[XCDR2]`
703 /// or `[XCDR2, XCDR1]` for bandwidth efficiency and
704 /// @appendable/@mutable support.
705 ///
706 /// Empty (`vec![]`) is interpreted per spec as `[XCDR1]`.
707 pub data_representation_offer: Vec<i16>,
708
709 /// D.5g — default match mode for DataRepresentation negotiation.
710 ///
711 /// `Strict` (XTypes 1.3 §7.6.3.1.2 normative): writer.first ∈
712 /// reader.list = match. `Tolerant` (industry norm): any overlap =
713 /// match, picks the first overlap as the wire format.
714 ///
715 /// Default `Tolerant` because Cyclone DDS and FastDDS match this way —
716 /// maximizes interop. The strict setting is only meaningful for
717 /// formal spec-compliance tests.
718 pub data_rep_match_mode: zerodds_rtps::publication_data::data_representation::DataRepMatchMode,
719
720 /// zerodds-async-1.0 §4 — when `true`, `start()` does **not** spawn the
721 /// dedicated `zdds-tick` std::thread. The periodic tick (SPDP announce,
722 /// SEDP/WLP, deadline/lifespan/liveliness) must then be driven externally
723 /// via [`DcpsRuntime::tick_driver`]. Used by the async API's
724 /// `spawn_in_tokio`, which multiplexes many participants' tick loops onto
725 /// a tokio runtime instead of one thread each. Default `false` (internal
726 /// thread, unchanged behaviour). The recv worker threads are unaffected —
727 /// they block on socket recv and stay regardless.
728 pub external_tick: bool,
729
730 /// D.5e Phase 3 — when `true`, `start()` drives the periodic tick via the
731 /// event-driven deadline scheduler ([`crate::scheduler`]) instead of the
732 /// fixed-`tick_period` poll: the worker parks until the next due deadline
733 /// (SPDP announce, or a fine floor while user endpoints/QoS timers are
734 /// active) or until a write/recv `raise` wakes it — no busy-poll, lower idle
735 /// CPU, lower tail latency. The work done per wake is the **unchanged**
736 /// `run_tick_iteration` (identical wire output + cadence — cross-vendor
737 /// safe). **Default `true`** since D.5e Phase C (2026-06-14) — set
738 /// `ZERODDS_SCHEDULER_TICK=0` or this field to `false` for the classic
739 /// fixed-period `tick_loop`. Mutually exclusive with `external_tick`
740 /// (external wins).
741 pub scheduler_tick: bool,
742}
743
744/// Configuration entry for a physical or logical network interface.
745///
746/// A binding describes an outbound socket: which IP/port it binds to,
747/// which `NetInterface` class the interface represents, and which IP
748/// range counts as "associated peers" (routing match).
749#[cfg(feature = "security")]
750#[derive(Clone, Debug)]
751pub struct InterfaceBindingSpec {
752 /// Name for diagnostics + log attribution (e.g. `"eth0"`, `"tun0"`,
753 /// `"lo"`).
754 pub name: String,
755 /// Bind address. `0.0.0.0` leaves the interface to the kernel.
756 pub bind_addr: Ipv4Addr,
757 /// Bind port. `0` = ephemeral.
758 pub bind_port: u16,
759 /// Interface class — feeds into the PolicyEngine context.
760 pub kind: NetInterface,
761 /// Destination IP range this binding is responsible for. Example:
762 /// `127.0.0.0/8` for loopback. A target whose IP lies in this range is
763 /// routed to this binding.
764 pub subnet: IpRange,
765 /// If `true`: this binding is used when **no** other subnet match
766 /// applies. Exactly one entry should have `default = true` (usually
767 /// the WAN binding).
768 pub default: bool,
769}
770
771/// Fully bound interface with its UDP socket.
772#[cfg(feature = "security")]
773struct InterfaceBinding {
774 spec: InterfaceBindingSpec,
775 socket: Arc<UdpTransport>,
776}
777
778/// Pool of per-interface UDP sockets with target-based routing.
779///
780/// Decision:
781/// 1. Iterates over all bindings; the first whose subnet contains the
782/// target wins.
783/// 2. If no match and a default binding exists → default path.
784/// 3. No match + no default → `None`, the caller drops.
785#[cfg(feature = "security")]
786struct OutboundSocketPool {
787 bindings: Vec<InterfaceBinding>,
788 default_idx: Option<usize>,
789}
790
791#[cfg(feature = "security")]
792impl OutboundSocketPool {
793 fn bind_all(specs: &[InterfaceBindingSpec]) -> Result<Self> {
794 let mut bindings = Vec::with_capacity(specs.len());
795 for spec in specs {
796 let socket = UdpTransport::bind_v4(spec.bind_addr, spec.bind_port).map_err(|_| {
797 DdsError::TransportError {
798 label: "interface-binding bind_v4 failed",
799 }
800 })?;
801 // Short read timeout so that the per-interface inbound poll in
802 // the event loop becomes non-blocking. 5 ms is small enough not
803 // to create latency elsewhere (the tick period defaults to
804 // 50 ms), but large enough to amortize context switches.
805 let socket = socket
806 .with_timeout(Some(Duration::from_millis(5)))
807 .map_err(|_| DdsError::TransportError {
808 label: "interface-binding set_timeout failed",
809 })?;
810 bindings.push(InterfaceBinding {
811 spec: spec.clone(),
812 socket: Arc::new(socket),
813 });
814 }
815 let default_idx = bindings.iter().position(|b| b.spec.default);
816 Ok(Self {
817 bindings,
818 default_idx,
819 })
820 }
821
822 /// Returns `(socket, NetInterface class)` for a destination locator.
823 /// `None` if neither a subnet match nor a default binding exists.
824 fn route(&self, target: &Locator) -> Option<(&Arc<UdpTransport>, NetInterface)> {
825 let ip = ipv4_from_locator(target)?;
826 let addr = core::net::IpAddr::V4(core::net::Ipv4Addr::from(ip));
827 for b in &self.bindings {
828 if b.spec.subnet.contains(&addr) {
829 return Some((&b.socket, b.spec.kind.clone()));
830 }
831 }
832 let idx = self.default_idx?;
833 let b = self.bindings.get(idx)?;
834 Some((&b.socket, b.spec.kind.clone()))
835 }
836}
837
838/// True if the locator is routable over the user-data transport
839/// (trait object). Accepts UDPv4, UDPv6, TCPv4, Shm. The concrete
840/// transport (UdpTransport/TcpTransport/ShmUserTransport) then returns
841/// `UnsupportedLocator` for kinds it does not itself speak;
842/// the filter here only prevents sending to clearly non-IP/SHM
843/// locators like UDS (for which we have no transport plugin).
844fn is_routable_user_locator(loc: &Locator) -> bool {
845 matches!(
846 loc.kind,
847 LocatorKind::UdpV4
848 | LocatorKind::UdpV6
849 | LocatorKind::Tcpv4
850 | LocatorKind::Tcpv6
851 | LocatorKind::Shm
852 | LocatorKind::Uds
853 | LocatorKind::Tsn
854 )
855}
856
857/// Computes the user-endpoint `EndpointSecurityInfo` mask from the governance
858/// protection kinds (DDS-Security 1.2 §10.4.1.2.6 / §9.4.2.4). The wire mask
859/// MUST match cyclone/FastDDS/OpenDDS byte-exactly, otherwise the peer rejects
860/// the endpoint match with "security_attributes mismatch".
861///
862/// - metadata=SIGN/ENCRYPT → IS_SUBMESSAGE_PROTECTED (+ plugin SUBMESSAGE_ENCRYPTED on ENCRYPT)
863/// - data=SIGN → IS_PAYLOAD_PROTECTED
864/// - data=ENCRYPT → IS_PAYLOAD_PROTECTED | **IS_KEY_PROTECTED** (+ plugin PAYLOAD_ENCRYPTED)
865/// - liveliness=SIGN/ENCRYPT → **IS_LIVELINESS_PROTECTED** (§9.4.1.3: per-endpoint!)
866/// - topic enable_discovery_protection → IS_DISCOVERY_PROTECTED
867///
868/// is_key_protected follows §10.4.1.2.6 exclusively from the **DATA** protection
869/// and only on ENCRYPT — NOT from the metadata protection. is_liveliness_protected
870/// in contrast MUST be on every user endpoint as soon as liveliness_protection is active;
871/// cyclone compares the mask at endpoint match and otherwise rejects with
872/// "security_attributes mismatch" (0x..30 vs 0x..70).
873#[cfg(feature = "security")]
874fn compute_user_endpoint_attrs(
875 meta: ProtectionLevel,
876 data: ProtectionLevel,
877 discovery_protected: bool,
878 liveliness_protected: bool,
879 read_protected: bool,
880 write_protected: bool,
881) -> zerodds_rtps::endpoint_security_info::EndpointSecurityInfo {
882 use zerodds_rtps::endpoint_security_info::{EndpointSecurityInfo, attrs, plugin_attrs};
883 let mut a = attrs::IS_VALID;
884 let mut p = plugin_attrs::IS_VALID;
885 if read_protected {
886 a |= attrs::IS_READ_PROTECTED;
887 }
888 if write_protected {
889 a |= attrs::IS_WRITE_PROTECTED;
890 }
891 if meta != ProtectionLevel::None {
892 a |= attrs::IS_SUBMESSAGE_PROTECTED;
893 }
894 if meta == ProtectionLevel::Encrypt {
895 p |= plugin_attrs::IS_SUBMESSAGE_ENCRYPTED;
896 }
897 if data != ProtectionLevel::None {
898 a |= attrs::IS_PAYLOAD_PROTECTED;
899 }
900 if data == ProtectionLevel::Encrypt {
901 a |= attrs::IS_KEY_PROTECTED;
902 p |= plugin_attrs::IS_PAYLOAD_ENCRYPTED;
903 }
904 if discovery_protected {
905 a |= attrs::IS_DISCOVERY_PROTECTED;
906 }
907 if liveliness_protected {
908 a |= attrs::IS_LIVELINESS_PROTECTED;
909 }
910 EndpointSecurityInfo {
911 endpoint_security_attributes: a,
912 plugin_endpoint_security_attributes: p,
913 }
914}
915
916#[cfg(all(test, feature = "security"))]
917mod endpoint_attr_tests {
918 use super::compute_user_endpoint_attrs;
919 use zerodds_rtps::endpoint_security_info::attrs;
920 use zerodds_security_runtime::ProtectionLevel;
921
922 fn mask(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
923 compute_user_endpoint_attrs(meta, data, false, false, false, false)
924 .endpoint_security_attributes
925 }
926
927 fn mask_liv(meta: ProtectionLevel, data: ProtectionLevel) -> u32 {
928 compute_user_endpoint_attrs(meta, data, false, true, false, false)
929 .endpoint_security_attributes
930 }
931
932 #[test]
933 fn liveliness_protected_sets_0x40_per_spec_9_4_1_3() {
934 use ProtectionLevel::{Encrypt, None};
935 let v = attrs::IS_VALID;
936 let pay = attrs::IS_PAYLOAD_PROTECTED;
937 let key = attrs::IS_KEY_PROTECTED;
938 let liv = attrs::IS_LIVELINESS_PROTECTED;
939 // liveliness=ENCRYPT + data=ENCRYPT → 0x..70 (cyclone's value at match).
940 assert_eq!(mask_liv(None, Encrypt), v | pay | key | liv);
941 // without liveliness → 0x..30, NO 0x40.
942 assert_eq!(mask(None, Encrypt), v | pay | key);
943 assert_eq!(mask_liv(None, None), v | liv);
944 }
945
946 #[test]
947 fn key_protected_follows_data_encrypt_per_spec_10_4_1_2_6() {
948 use ProtectionLevel::{Encrypt, None, Sign};
949 let v = attrs::IS_VALID;
950 let sub = attrs::IS_SUBMESSAGE_PROTECTED;
951 let pay = attrs::IS_PAYLOAD_PROTECTED;
952 let key = attrs::IS_KEY_PROTECTED;
953 // §10.4.1.2.6: is_key_protected follows ONLY data=ENCRYPT.
954 // data=ENCRYPT → PAYLOAD|KEY (= cyclone's 0x30 in the common subset).
955 assert_eq!(mask(None, Encrypt), v | pay | key);
956 // data=SIGN → PAYLOAD, NO KEY.
957 assert_eq!(mask(None, Sign), v | pay);
958 // data=NONE → no payload/key bits.
959 assert_eq!(mask(None, None), v);
960 // KEY does NOT depend on metadata: meta=ENCRYPT/data=NONE → only SUBMESSAGE.
961 assert_eq!(mask(Encrypt, None), v | sub);
962 // meta=ENCRYPT + data=ENCRYPT → SUBMESSAGE|PAYLOAD|KEY (0x38).
963 assert_eq!(mask(Encrypt, Encrypt), v | sub | pay | key);
964 }
965}
966
967/// Unicast targets for the WLP heartbeat fan-out (M-2): per discovered peer the
968/// `metatraffic_unicast_locator` (fallback `default_unicast_locator`), filtered
969/// to routable kinds. WLP is metatraffic (DDSI-RTPS §8.4.13); in multicast-
970/// free environments (container/cloud) the pure multicast pulse never reaches the
971/// peer reader → the lease expires although the peer is alive. The additional
972/// unicast fan-out follows the SEDP locator model.
973fn wlp_unicast_targets(peers: &[zerodds_discovery::spdp::DiscoveredParticipant]) -> Vec<Locator> {
974 peers
975 .iter()
976 .filter_map(|dp| {
977 dp.data
978 .metatraffic_unicast_locator
979 .or(dp.data.default_unicast_locator)
980 })
981 .filter(is_routable_user_locator)
982 .collect()
983}
984
985/// Extracts the IPv4 address from a `Locator` (UDP-V4).
986/// `None` for SHM/UDS/IPv6.
987#[cfg(feature = "security")]
988fn ipv4_from_locator(loc: &Locator) -> Option<[u8; 4]> {
989 if loc.kind != LocatorKind::UdpV4 {
990 return None;
991 }
992 Some([
993 loc.address[12],
994 loc.address[13],
995 loc.address[14],
996 loc.address[15],
997 ])
998}
999
1000impl core::fmt::Debug for RuntimeConfig {
1001 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1002 let mut dbg = f.debug_struct("RuntimeConfig");
1003 dbg.field("tick_period", &self.tick_period)
1004 .field("spdp_period", &self.spdp_period)
1005 .field("spdp_multicast_group", &self.spdp_multicast_group)
1006 .field("multicast_interface", &self.multicast_interface);
1007 #[cfg(feature = "security")]
1008 {
1009 dbg.field("security", &self.security.as_ref().map(|_| "<gate>"));
1010 dbg.field(
1011 "security_logger",
1012 &self.security_logger.as_ref().map(|_| "<logger>"),
1013 );
1014 }
1015 dbg.finish()
1016 }
1017}
1018
1019impl Default for RuntimeConfig {
1020 fn default() -> Self {
1021 // Env hook for bench tuning: ZERODDS_TICK_PERIOD_MS=N → overrides
1022 // the 5ms default. High (e.g. 1000) relieves the write hot path of
1023 // the periodic HB/tick overhead and makes spread spikes from tick
1024 // preemption visible. Production: do not set; the default 5 ms is
1025 // spec-compliant.
1026 let tick = std::env::var("ZERODDS_TICK_PERIOD_MS")
1027 .ok()
1028 .and_then(|s| s.parse::<u64>().ok())
1029 .map(Duration::from_millis)
1030 .unwrap_or(DEFAULT_TICK_PERIOD);
1031 // C3 WiFi-robust discovery — initial-announcement burst. Env overrides:
1032 // `ZERODDS_INITIAL_ANNOUNCE_COUNT` (0 disables) +
1033 // `ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS`.
1034 let initial_announce_count = std::env::var("ZERODDS_INITIAL_ANNOUNCE_COUNT")
1035 .ok()
1036 .and_then(|s| s.parse::<u32>().ok())
1037 .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_COUNT);
1038 let initial_announce_period = std::env::var("ZERODDS_INITIAL_ANNOUNCE_PERIOD_MS")
1039 .ok()
1040 .and_then(|s| s.parse::<u64>().ok())
1041 .map(Duration::from_millis)
1042 .unwrap_or(DEFAULT_INITIAL_ANNOUNCE_PERIOD);
1043 Self {
1044 tick_period: tick,
1045 spdp_period: DEFAULT_SPDP_PERIOD,
1046 initial_announce_count,
1047 initial_announce_period,
1048 // Env override `ZERODDS_SPDP_MC_GROUP` (IPv4) of the SPDP
1049 // multicast group. Two processes with different groups do NOT
1050 // see each other via multicast → enables a multicast-free C1
1051 // e2e proof (discovery then only via ZERODDS_PEERS). Default is
1052 // the spec group.
1053 spdp_multicast_group: std::env::var("ZERODDS_SPDP_MC_GROUP")
1054 .ok()
1055 .and_then(|s| s.parse::<Ipv4Addr>().ok())
1056 .unwrap_or_else(|| Ipv4Addr::from(SPDP_DEFAULT_MULTICAST_ADDRESS)),
1057 // Interface pinning (Cyclone `NetworkInterface`/FastDDS
1058 // whitelist equivalent): `ZERODDS_INTERFACE=<ipv4>` forces
1059 // announce + bind on this interface. Default UNSPECIFIED = auto
1060 // (route probe). Critical on multi-homed hosts (VPN/Docker/
1061 // macOS bridge100), where the auto choice may announce the
1062 // wrong interface.
1063 multicast_interface: std::env::var("ZERODDS_INTERFACE")
1064 .ok()
1065 .and_then(|s| s.parse::<Ipv4Addr>().ok())
1066 .unwrap_or(Ipv4Addr::UNSPECIFIED),
1067 // Multicast send on by default; `ZERODDS_NO_MULTICAST` (any
1068 // non-empty value) turns it off → pure unicast discovery.
1069 spdp_multicast_send: std::env::var("ZERODDS_NO_MULTICAST")
1070 .map(|v| v.is_empty())
1071 .unwrap_or(true),
1072 // C3: 16 MiB default (suitable for ROS PointCloud2/Image),
1073 // env override `ZERODDS_MAX_SAMPLE_BYTES`.
1074 max_reassembly_sample_bytes: std::env::var("ZERODDS_MAX_SAMPLE_BYTES")
1075 .ok()
1076 .and_then(|s| s.parse::<usize>().ok())
1077 .unwrap_or(16 * 1024 * 1024),
1078 // Programmatic default empty. The env `ZERODDS_PEERS` is
1079 // expanded domain-aware only in `DcpsRuntime::start` and merged
1080 // with this field into the effective peer list.
1081 initial_peers: Vec::new(),
1082 user_transport: None,
1083 user_transports: alloc::vec::Vec::new(),
1084 #[cfg(feature = "security")]
1085 security: None,
1086 #[cfg(feature = "security")]
1087 security_logger: None,
1088 #[cfg(feature = "security")]
1089 interface_bindings: Vec::new(),
1090 announce_secure_endpoints: false,
1091 // Env hook for bench/FastDDS interop: ZERODDS_SECURE_SPDP=1 turns
1092 // on the reliable secure SPDP channel (0xff0101). Production sets this
1093 // explicitly via the SecurityProfile/config.
1094 enable_secure_spdp: std::env::var("ZERODDS_SECURE_SPDP").ok().as_deref() == Some("1"),
1095 wlp_period: Duration::ZERO,
1096 participant_lease_duration: Duration::from_secs(100),
1097 user_data: Vec::new(),
1098 observability: zerodds_foundation::observability::null_sink(),
1099 recv_thread_priority: None,
1100 tick_thread_priority: None,
1101 recv_thread_cpus: None,
1102 tick_thread_cpus: None,
1103 extra_recv_threads: 0,
1104 // D.5g — default `[XCDR1, XCDR2]` (legacy-first, max interop).
1105 // Env-var override `ZERODDS_DATA_REPR_OFFER` as a comma list
1106 // ("XCDR1", "XCDR2", "XCDR1,XCDR2", "XCDR2,XCDR1"). Cross-vendor
1107 // benches against strict-matching vendors (RTI) need XCDR2-only
1108 // so that every wire match happens.
1109 data_representation_offer: parse_data_repr_offer_env().unwrap_or_else(|| {
1110 zerodds_rtps::publication_data::data_representation::DEFAULT_OFFER.to_vec()
1111 }),
1112 data_rep_match_mode:
1113 zerodds_rtps::publication_data::data_representation::DataRepMatchMode::default(),
1114 external_tick: false,
1115 // D.5e Phase 3 — the event-driven deadline-heap scheduler is the
1116 // DEFAULT tick (Phase C, 2026-06-14): it parks until the next due
1117 // deadline / a write-recv raise instead of polling every 5 ms (~17×
1118 // fewer idle iterations, lower tail latency, identical wire output).
1119 // Verified cross-vendor secured (data-enc + rtps-enc all pairs) +
1120 // same_host_e2e + latency_assertions on codepit. Escape hatch:
1121 // `ZERODDS_SCHEDULER_TICK=0` restores the classic fixed-period
1122 // `tick_loop`.
1123 scheduler_tick: std::env::var("ZERODDS_SCHEDULER_TICK")
1124 .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false")))
1125 .unwrap_or(true),
1126 }
1127 }
1128}
1129
1130impl RuntimeConfig {
1131 /// Apply a [`SecurityBundle`](zerodds_security_runtime::SecurityBundle):
1132 /// wires its security-event logger into [`Self::security_logger`] and, if
1133 /// the bundle carries a [`SecurityProfile`](zerodds_security_runtime::SecurityProfile),
1134 /// its gate into [`Self::security`]. Convenience for the common
1135 /// `SecurityBundle::builder()…build()` flow so callers don't have to set
1136 /// the two fields by hand.
1137 #[cfg(feature = "security")]
1138 #[must_use]
1139 pub fn with_security_bundle(
1140 mut self,
1141 bundle: &zerodds_security_runtime::SecurityBundle,
1142 ) -> Self {
1143 if let Some(logger) = bundle.logging_plugin() {
1144 self.security_logger = Some(logger);
1145 }
1146 if let Some(profile) = bundle.security_profile() {
1147 self.security = Some(profile.gate.clone());
1148 }
1149 self
1150 }
1151
1152 /// Materialize a security-event logger from `dds.sec.log.*` properties and
1153 /// wire it into [`Self::security_logger`]. This is the DDS-Security
1154 /// spec-style alternative to handing a logger object in directly (see
1155 /// [`Self::with_security_bundle`]): the participant carries
1156 /// `dds.sec.log.plugin = "stderr,jsonl"` (+ `dds.sec.log.*` parameters) on
1157 /// its [`PropertyQosPolicy`](zerodds_qos::PropertyQosPolicy), and the
1158 /// runtime builds the fan-out logger from them.
1159 ///
1160 /// No-op when `dds.sec.log.plugin` is absent. Errors if a selected sink is
1161 /// misconfigured (e.g. `jsonl` without `dds.sec.log.jsonl.path`).
1162 #[cfg(feature = "security")]
1163 pub fn with_security_log_properties(
1164 mut self,
1165 property: &zerodds_qos::PropertyQosPolicy,
1166 ) -> core::result::Result<Self, zerodds_security_logging::LogConfigError> {
1167 let pairs: alloc::vec::Vec<(&str, &str)> = property.iter().collect();
1168 if let Some(logger) = zerodds_security_logging::logging_plugin_from_properties(&pairs)? {
1169 self.security_logger = Some(alloc::sync::Arc::from(logger));
1170 }
1171 Ok(self)
1172 }
1173
1174 /// C4: robotics-capable defaults for **out-of-the-box ROS-2 interop**.
1175 /// Saves the manual env tuning otherwise needed for real ROS-2 nodes.
1176 /// Specifically, compared to [`RuntimeConfig::default`]:
1177 /// - **`data_representation_offer = [XCDR1, XCDR2]`**: `rmw_cyclonedds`/
1178 /// `rmw_fastrtps` write XCDR1 for final/simple types (e.g.
1179 /// `std_msgs/String`). An XCDR2-only reader does not match an XCDR1
1180 /// writer — so the ROS reader here offers both legacy-first
1181 /// (tolerant match is already the default). This is the clean,
1182 /// ROS-specific variant of the `ZERODDS_DATA_REPR_OFFER` env
1183 /// workaround, WITHOUT changing the global `DEFAULT_OFFER`
1184 /// (XCDR2-only, deliberately for FastDDS/OpenDDS XCDR2 readers).
1185 ///
1186 /// The ROS-realistic reassembly cap (16 MiB, PointCloud2/Image) is
1187 /// already the global default and is carried over here.
1188 #[must_use]
1189 pub fn ros_defaults() -> Self {
1190 use zerodds_rtps::publication_data::data_representation as dr;
1191 Self {
1192 data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1193 ..Self::default()
1194 }
1195 }
1196
1197 /// C6 multi-robot / WAN / cross-subnet profile.
1198 ///
1199 /// A named profile for fleets that span subnets, the cloud, or WiFi —
1200 /// environments that drop IP multicast, so SPDP discovery cannot rely on
1201 /// the multicast beacon. It is the [`ros_defaults`](Self::ros_defaults)
1202 /// representation offer **plus**:
1203 ///
1204 /// - **Multicast-free discovery** (`spdp_multicast_send = false`):
1205 /// participants find each other purely through unicast initial peers,
1206 /// regardless of the `ZERODDS_NO_MULTICAST` env. Set the peers via
1207 /// `ZERODDS_PEERS` (a comma list of `ip` or `ip:port`); a port-less
1208 /// `ip` is expanded to the well-known SPDP unicast ports of the first
1209 /// N participant indices (`ZERODDS_MAX_PEER_PARTICIPANTS`).
1210 /// - **WAN-tolerant liveliness**: a longer participant lease (300 s vs
1211 /// the 100 s spec default) so transient cross-subnet RTT spikes or
1212 /// brief link drops do not trigger a false liveliness loss.
1213 ///
1214 /// **Domain isolation** is the caller's lever: pass a fleet-dedicated
1215 /// `domain_id` to [`DcpsRuntime::start`] to keep robots off the default
1216 /// domain 0. The profile deliberately does not pick a domain for you.
1217 ///
1218 /// ```
1219 /// use zerodds_dcps::runtime::RuntimeConfig;
1220 /// let cfg = RuntimeConfig::multi_robot();
1221 /// assert!(!cfg.spdp_multicast_send); // unicast-only discovery
1222 /// ```
1223 pub fn multi_robot() -> Self {
1224 use zerodds_rtps::publication_data::data_representation as dr;
1225 Self {
1226 data_representation_offer: alloc::vec![dr::XCDR, dr::XCDR2],
1227 spdp_multicast_send: false,
1228 participant_lease_duration: Duration::from_secs(300),
1229 ..Self::default()
1230 }
1231 }
1232}
1233
1234/// Parse the `ZERODDS_DATA_REPR_OFFER` env var. Values: "XCDR1", "XCDR2",
1235/// or a comma list. None if the env var is missing or invalid.
1236fn parse_data_repr_offer_env() -> Option<Vec<i16>> {
1237 let s = std::env::var("ZERODDS_DATA_REPR_OFFER").ok()?;
1238 parse_data_repr_offer_str(&s)
1239}
1240
1241/// Computes the **well-known** SPDP unicast discovery port for a
1242/// domain + participant index. Formula (DDSI-RTPS 2.5 §9.6.1.4.1):
1243/// port = PB + DG·domain + d1 + PG·pid = 7400 + 250·domain + 10 + 2·pid
1244///
1245/// This lets a configured unicast initial peer (multicast-free discovery)
1246/// reach a participant deterministically WITHOUT having found it via
1247/// multicast first. Defined locally in `dcps` to avoid touching
1248/// `crates/rtps` (spec constants as literals).
1249#[must_use]
1250fn spdp_unicast_port(domain_id: u32, participant_id: u32) -> u32 {
1251 7400 + 250 * domain_id + 10 + 2 * participant_id
1252}
1253
1254/// Default number of participant indices a port-less initial peer is
1255/// expanded to (Cyclone equivalent: `MaxAutoParticipantIndex`). The
1256/// beacon thereby reaches the first N participants of the peer host via
1257/// their well-known SPDP unicast ports. Overridable via the env
1258/// `ZERODDS_MAX_PEER_PARTICIPANTS` (e.g. for dense multi-robot / >10
1259/// participants-per-host scenarios). Cap 120 (= the well-known-port
1260/// allocation window).
1261const INITIAL_PEER_MAX_PARTICIPANTS: u32 = 10;
1262
1263/// Effective peer-expansion limit: env `ZERODDS_MAX_PEER_PARTICIPANTS`
1264/// or [`INITIAL_PEER_MAX_PARTICIPANTS`], clamped to 1..=120.
1265fn initial_peer_max_participants() -> u32 {
1266 std::env::var("ZERODDS_MAX_PEER_PARTICIPANTS")
1267 .ok()
1268 .and_then(|s| s.parse::<u32>().ok())
1269 .unwrap_or(INITIAL_PEER_MAX_PARTICIPANTS)
1270 .clamp(1, 120)
1271}
1272
1273/// C1 multicast-free discovery: parses the env `ZERODDS_PEERS` (comma
1274/// list of `ip` or `ip:port`) into SPDP unicast initial-peer locators for
1275/// `domain_id`. Empty/invalid → empty list.
1276fn parse_initial_peers_env(domain_id: u32) -> Vec<Locator> {
1277 let mut out = Vec::new();
1278 let max = initial_peer_max_participants();
1279 if let Ok(s) = std::env::var("ZERODDS_PEERS") {
1280 for entry in s.split(',') {
1281 expand_initial_peer(entry.trim(), domain_id, max, &mut out);
1282 }
1283 }
1284 out
1285}
1286
1287/// Expands a single peer spec into locator(s) and appends them to `out`.
1288/// `ip:port` → exact locator. Just `ip` → well-known SPDP unicast ports
1289/// of participant indices `0..max_participants` (Spec §9.6.1.4.1).
1290/// Invalid specs are ignored.
1291fn expand_initial_peer(spec: &str, domain_id: u32, max_participants: u32, out: &mut Vec<Locator>) {
1292 if spec.is_empty() {
1293 return;
1294 }
1295 if let Some((ip_s, port_s)) = spec.rsplit_once(':') {
1296 if let (Ok(ip), Ok(port)) = (ip_s.parse::<Ipv4Addr>(), port_s.parse::<u16>()) {
1297 out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1298 return;
1299 }
1300 }
1301 if let Ok(ip) = spec.parse::<Ipv4Addr>() {
1302 for pid in 0..max_participants {
1303 if let Ok(port) = u16::try_from(spdp_unicast_port(domain_id, pid)) {
1304 out.push(Locator::udp_v4(ip.octets(), u32::from(port)));
1305 }
1306 }
1307 }
1308}
1309
1310/// Pure parser for the `ZERODDS_DATA_REPR_OFFER` syntax (testable without
1311/// env). Returns the DataRepresentationId list with the **spec values**
1312/// `XCDR=0`, `XCDR2=2` (XTypes 1.3 §7.6.3.1.2) — NOT version numbers.
1313/// `None` on empty/invalid input.
1314fn parse_data_repr_offer_str(s: &str) -> Option<Vec<i16>> {
1315 use zerodds_rtps::publication_data::data_representation as dr;
1316 let mut out = Vec::new();
1317 for tok in s.split(',').map(str::trim) {
1318 let v = match tok.to_ascii_uppercase().as_str() {
1319 "XCDR1" | "XCDR" | "1" => dr::XCDR,
1320 "XCDR2" | "2" => dr::XCDR2,
1321 _ => return None,
1322 };
1323 out.push(v);
1324 }
1325 if out.is_empty() { None } else { Some(out) }
1326}
1327
1328// ---------------------------------------------------------------------------
1329// Security-gate helpers
1330// ---------------------------------------------------------------------------
1331
1332/// Pull outbound UDP bytes through the security gate (when configured).
1333/// Without the `security` feature or without a gate: pass-through (clone
1334/// as Vec).
1335///
1336/// Errors in the gate are logged silently and the packet is **not** sent —
1337/// better to drop than leak plaintext.
1338/// DDS-Security 8.4.2.4: the RTPS message protection (message-level SRTPS)
1339/// does NOT apply to bootstrap traffic that must flow BEFORE the participant
1340/// crypto-key exchange: SPDP (participant discovery, to everyone) and the
1341/// ParticipantStatelessMessage (auth handshake). Wrapping them in SRTPS would
1342/// mean a not-yet-authenticated peer could not decrypt them
1343/// (no key) -> discovery/auth breaks (match timeout pub=0 sub=0). Detection
1344/// via the writer EntityId of the DATA/DATA_FRAG submessages.
1345#[cfg(feature = "security")]
1346fn rtps_message_protection_exempt(
1347 bytes: &[u8],
1348 discovery_plain: bool,
1349 liveliness_plain: bool,
1350) -> bool {
1351 use zerodds_rtps::wire_types::EntityId;
1352 // Bootstrap endpoints (§8.4.2.4): SPDP/Stateless/Volatile ALWAYS flow
1353 // plain (before/during key exchange resp. their own submessage protection).
1354 // Discovery plane (SEDP pub/sub, TypeLookup) is plain when discovery_
1355 // protection_kind=NONE; WLP (ParticipantMessage) plain when liveliness_
1356 // protection_kind=NONE. cyclone<->cyclone reference capture: under rtps_
1357 // protection=ENCRYPT + discovery=NONE cyclone sends the ENTIRE discovery
1358 // plane (DATA+HEARTBEAT+ACKNACK) PLAINTEXT — only user DATA is SRTPS-
1359 // wrapped. ZeroDDS must mirror this, otherwise it drops cyclone's plain
1360 // SubscriptionData as legacy_blocked -> no user-endpoint match.
1361 let entity_exempt = |e: EntityId| -> bool {
1362 matches!(
1363 e,
1364 EntityId::SPDP_BUILTIN_PARTICIPANT_WRITER
1365 | EntityId::SPDP_BUILTIN_PARTICIPANT_READER
1366 | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
1367 | EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
1368 | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
1369 | EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
1370 ) || (discovery_plain
1371 && matches!(
1372 e,
1373 EntityId::SEDP_BUILTIN_PUBLICATIONS_WRITER
1374 | EntityId::SEDP_BUILTIN_PUBLICATIONS_READER
1375 | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_WRITER
1376 | EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_READER
1377 | EntityId::TL_SVC_REQ_WRITER
1378 | EntityId::TL_SVC_REQ_READER
1379 | EntityId::TL_SVC_REPLY_WRITER
1380 | EntityId::TL_SVC_REPLY_READER
1381 ))
1382 || (liveliness_plain
1383 && matches!(
1384 e,
1385 EntityId::BUILTIN_PARTICIPANT_MESSAGE_WRITER
1386 | EntityId::BUILTIN_PARTICIPANT_MESSAGE_READER
1387 ))
1388 };
1389 let Ok(parsed) = decode_datagram(bytes) else {
1390 return false;
1391 };
1392 // Datagram exempt if it has at least one relevant submessage AND
1393 // ALL relevant ones are exempt (.all) — otherwise a bundled
1394 // exempt+non-exempt datagram leaks the protection-worthy submessage.
1395 let relevant: alloc::vec::Vec<bool> = parsed
1396 .submessages
1397 .iter()
1398 .filter_map(|sm| match sm {
1399 ParsedSubmessage::Data(d) => {
1400 Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1401 }
1402 ParsedSubmessage::DataFrag(d) => {
1403 Some(entity_exempt(d.reader_id) || entity_exempt(d.writer_id))
1404 }
1405 ParsedSubmessage::Heartbeat(h) => {
1406 Some(entity_exempt(h.reader_id) || entity_exempt(h.writer_id))
1407 }
1408 ParsedSubmessage::AckNack(a) => {
1409 Some(entity_exempt(a.reader_id) || entity_exempt(a.writer_id))
1410 }
1411 ParsedSubmessage::Gap(g) => {
1412 Some(entity_exempt(g.reader_id) || entity_exempt(g.writer_id))
1413 }
1414 ParsedSubmessage::NackFrag(n) => {
1415 Some(entity_exempt(n.reader_id) || entity_exempt(n.writer_id))
1416 }
1417 // SEC_PREFIX (Kx-Volatile, inner writer-id encrypted) -> exempt.
1418 ParsedSubmessage::Unknown { id: 0x31, .. } => Some(true),
1419 // Framing (INFO_DST/INFO_TS/...) -> neutral.
1420 _ => None,
1421 })
1422 .collect();
1423 !relevant.is_empty() && relevant.iter().all(|&b| b)
1424}
1425
1426#[cfg(feature = "security")]
1427fn secure_outbound_bytes<'a>(
1428 rt: &DcpsRuntime,
1429 bytes: &'a [u8],
1430) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1431 match &rt.config.security {
1432 // OUTBOUND is spec-strict (DDS-Security 8.4.2.4 Table 27 is_rtps_protected):
1433 // under rtps_protection the ENTIRE RTPS message is SRTPS-wrapped; ONLY the
1434 // "separate messages" (SPDP/Stateless/Volatile) flow plain. SEDP/WLP/
1435 // TypeLookup are NOT among them and must be wrapped — independent
1436 // of discovery_/liveliness_protection (those are orthogonal submessage layers).
1437 // -> discovery_plain=false, liveliness_plain=false forces the wrap.
1438 // OpenDDS' RtpsUdpReceiveStrategy::check_encoded otherwise drops every plain SEDP
1439 // as "Full message requires protection". cyclone does take the shortcut
1440 // (sends SEDP plain), but accepts wrapped SEDP inbound without issue.
1441 // The INBOUND path (secure_inbound_bytes) deliberately stays lenient and still
1442 // accepts cyclone's plain SEDP — the asymmetry is intentional.
1443 Some(gate) if rtps_message_protection_exempt(bytes, false, false) => {
1444 let _ = gate;
1445 Some(alloc::borrow::Cow::Borrowed(bytes))
1446 }
1447 Some(gate) => gate
1448 .transform_outbound(bytes)
1449 .ok()
1450 .map(alloc::borrow::Cow::Owned),
1451 None => Some(alloc::borrow::Cow::Borrowed(bytes)),
1452 }
1453}
1454
1455// Security off: no clone — the caller borrows the datagram bytes
1456// directly (copy 6 of the zero-copy audit eliminated).
1457#[cfg(not(feature = "security"))]
1458fn secure_outbound_bytes<'a>(
1459 _rt: &DcpsRuntime,
1460 bytes: &'a [u8],
1461) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1462 Some(alloc::borrow::Cow::Borrowed(bytes))
1463}
1464
1465/// Pull inbound UDP bytes through the security gate.
1466///
1467/// Expects an RTPS header with the GuidPrefix at bytes 8..20.
1468/// `None` → drop the packet.
1469///
1470/// Security: drop reasons are forwarded, differentiated, to the
1471/// configured `LoggingPlugin`:
1472/// * `Malformed` → `Error`
1473/// * `LegacyBlocked` → `Error`
1474/// * `PolicyViolation` → `Warning` (possible tampering)
1475/// * `CryptoError` → `Warning` (tag mismatch, replay etc.)
1476#[cfg(feature = "security")]
1477fn secure_inbound_bytes<'a>(
1478 rt: &DcpsRuntime,
1479 bytes: &'a [u8],
1480 iface: &NetInterface,
1481) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1482 use zerodds_security_runtime::{InboundVerdict, LogLevel};
1483 let Some(gate) = &rt.config.security else {
1484 return Some(alloc::borrow::Cow::Borrowed(bytes));
1485 };
1486 // DDS-Security 8.4.2.4 (symmetric to outbound): SPDP/Stateless are
1487 // message-protection-exempt and ALWAYS arrive plain (also from cyclone). Without
1488 // this exception classify_inbound discards plain SPDP on the WAN iface under
1489 // rtps_protection as LegacyBlocked -> no discovery (match timeout).
1490 {
1491 let looks_srtps = bytes.len() > 20usize && bytes[20usize] == 0x33;
1492 if !looks_srtps
1493 && rtps_message_protection_exempt(
1494 bytes,
1495 gate.discovery_protection().unwrap_or(ProtectionLevel::None)
1496 == ProtectionLevel::None,
1497 gate.liveliness_protection()
1498 .unwrap_or(ProtectionLevel::None)
1499 == ProtectionLevel::None,
1500 )
1501 {
1502 // SRTPS-exempt. BUT metadata_protection user DATA carries per-submessage
1503 // SEC_PREFIX/BODY/POSTFIX (§9.5.3.3, NO SRTPS) — that must still be
1504 // decrypted per-endpoint here, otherwise the reader gets the
1505 // SEC wrapper instead of the DATA. Volatile-Kx-SEC fails with None
1506 // (key_id not in user-remote_by_key_id) -> unchanged for the
1507 // Volatile handler in the metatraffic loop.
1508 if walk_submessages(bytes)
1509 .iter()
1510 .any(|(id, _, _)| *id == SMID_SEC_PREFIX)
1511 {
1512 let mut pk = [0u8; 12];
1513 pk.copy_from_slice(&bytes[8..20]);
1514 if let Some(mut dg) = unprotect_user_datagram(rt, bytes, &pk) {
1515 match unprotect_user_payload(rt, &dg) {
1516 PayloadDecode::Decoded(clear) => dg = clear,
1517 PayloadDecode::Failed => return None,
1518 PayloadDecode::NotEncrypted => {}
1519 }
1520 return Some(alloc::borrow::Cow::Owned(dg));
1521 }
1522 }
1523 return Some(alloc::borrow::Cow::Borrowed(bytes));
1524 }
1525 }
1526 let verdict = gate.classify_inbound(bytes, iface);
1527 let category = verdict.category();
1528 let (level, message): (LogLevel, String) = match &verdict {
1529 InboundVerdict::Accept(out) => {
1530 // Cross-vendor user DATA: cyclone protects the DATA submessage as a
1531 // SEC_PREFIX/BODY/POSTFIX sequence (metadata_protection=ENCRYPT). Before
1532 // the submessage parse, transform it back with the sender's data key
1533 // (GuidPrefix = bytes[8..20]). `unprotect_user_datagram` returns
1534 // `None` when no SEC_* sequence is present → normal accept path.
1535 // OUTER layer first (metadata_protection, SEC_PREFIX/BODY/
1536 // POSTFIX), then the INNER one (data_protection, encrypted
1537 // SerializedPayload §9.5.3.3.1). Both can be active at once
1538 // (full secure profile); each returns `None` when its layer
1539 // is not present -> then the datagram stays unchanged.
1540 let mut dg: alloc::vec::Vec<u8> = out.clone();
1541 if dg.len() >= 20 {
1542 let mut pk = [0u8; 12];
1543 pk.copy_from_slice(&dg[8..20]);
1544 if let Some(clear) = unprotect_user_datagram(rt, &dg, &pk) {
1545 dg = clear;
1546 }
1547 }
1548 match unprotect_user_payload(rt, &dg) {
1549 PayloadDecode::Decoded(clear) => dg = clear,
1550 // Undecodable encrypted payload -> discard the datagram
1551 // (no ciphertext garbage to the reader; reliable re-send resp. another
1552 // copy delivers the sample later).
1553 PayloadDecode::Failed => return None,
1554 PayloadDecode::NotEncrypted => {}
1555 }
1556 return Some(alloc::borrow::Cow::Owned(dg));
1557 }
1558 InboundVerdict::Malformed => (
1559 LogLevel::Error,
1560 alloc::format!(
1561 "inbound datagram too short ({} bytes, iface={:?})",
1562 bytes.len(),
1563 iface
1564 ),
1565 ),
1566 InboundVerdict::LegacyBlocked => (
1567 LogLevel::Error,
1568 alloc::format!(
1569 "legacy plaintext peer on protected domain \
1570 (iface={iface:?}, allow_unauthenticated_participants=false)"
1571 ),
1572 ),
1573 InboundVerdict::PolicyViolation(msg) => {
1574 (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1575 }
1576 InboundVerdict::CryptoError(msg) => {
1577 (LogLevel::Warning, alloc::format!("{msg} [iface={iface:?}]"))
1578 }
1579 };
1580 if let Some(logger) = &rt.config.security_logger {
1581 // Participant ident: GuidPrefix (or 0-padding for Malformed).
1582 let mut participant = [0u8; 16];
1583 if bytes.len() >= 20 {
1584 participant[..12].copy_from_slice(&bytes[8..20]);
1585 }
1586 logger.log(level, participant, category, &message);
1587 }
1588 None
1589}
1590
1591#[cfg(not(feature = "security"))]
1592fn secure_inbound_bytes<'a>(
1593 _rt: &DcpsRuntime,
1594 bytes: &'a [u8],
1595) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1596 Some(alloc::borrow::Cow::Borrowed(bytes))
1597}
1598
1599/// Default interface class for inbound dispatch when the socket does not
1600/// belong to the `outbound_pool`. In the v1.4 setup (without
1601/// `interface_bindings`), all packets run through `user_unicast` and are
1602/// classified as `Wan` — the most conservative assumption (protection
1603/// rules apply as in the single-interface case).
1604#[cfg(feature = "security")]
1605const DEFAULT_INBOUND_IFACE: NetInterface = NetInterface::Wan;
1606
1607/// Per-reader outbound transform.
1608///
1609/// Looks up in the writer slot which `ProtectionLevel` the matched reader
1610/// expects at the given `target` locator, then pulls the datagram through
1611/// the security gate individually. This way each reader gets a wire
1612/// payload matching its security profile (Legacy=plain, Fast=Sign,
1613/// Secure=Encrypt).
1614///
1615/// Fallback paths:
1616/// * No security gate configured → passthrough.
1617/// * No `locator_to_peer` entry (reader not yet matched via SEDP) →
1618/// `transform_outbound` with the domain rule — that is the homogeneous
1619/// v1.4 path.
1620/// * The gate returns an error → `None` (the caller drops — better no
1621/// plaintext leak).
1622#[cfg(feature = "security")]
1623fn secure_outbound_for_target(
1624 rt: &DcpsRuntime,
1625 writer_eid: EntityId,
1626 bytes: &[u8],
1627 target: &Locator,
1628) -> Option<Vec<u8>> {
1629 let Some(gate) = &rt.config.security else {
1630 return Some(bytes.to_vec());
1631 };
1632 // FU2 S3: fallback level from our own governance (data_protection_
1633 // kind), in case the matched reader did not announce an explicit SEDP
1634 // security_info level. This way user data to an authenticated peer is
1635 // encrypted per our own governance, while SPDP/SEDP metatraffic
1636 // bootstraps plaintext over rtps_protection_kind=NONE.
1637 // Governance `data_protection` is a FLOOR, not a mere fallback: a
1638 // per-reader level can only STRENGTHEN (e.g. legacy plaintext is only
1639 // allowed if the domain policy itself permits plaintext), never fall
1640 // below the domain policy. Otherwise a matched-but-not-authenticated
1641 // peer (foreign CA, SEDP match over plaintext discovery,
1642 // reader_protection=None) leaks plaintext user data.
1643 let gov_data_level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1644 // metadata_protection (§8.4.2.4 / §9.5.3.3): EVERY writer submessage (DATA,
1645 // HEARTBEAT, GAP) is SEC_PREFIX/BODY/POSTFIX-wrapped per-submessage —
1646 // TARGET-INDEPENDENT, since the per-endpoint writer key is local (the peer fetches
1647 // it via datawriter_crypto_token). Must take effect BEFORE the locator-based reader
1648 // resolution: otherwise tick HEARTBEATs/GAPs to not-yet-locator-
1649 // matched targets fall into the None branch -> with rtps=NONE PLAIN -> leak + no
1650 // reliable recovery (breaks already zero<->zero). data_protection (inner
1651 // payload layer) first, then the outer submessage layer.
1652 if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1653 let inner = if gov_data_level != ProtectionLevel::None {
1654 protect_user_payload(rt, bytes)?
1655 } else {
1656 bytes.to_vec()
1657 };
1658 let meta_sec = protect_user_datagram(rt, &inner)?;
1659 // Under rtps_protection message-level SRTPS MUST additionally go on top —
1660 // BOTH layers, like cyclone<->cyclone. Without it the peer would see the
1661 // metadata-SEC-DATA as "clear submsg from protected src" and discard it.
1662 if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1663 return gate.transform_outbound(&meta_sec).ok();
1664 }
1665 return Some(meta_sec);
1666 }
1667 let resolved = rt.writer_slot(writer_eid).and_then(|arc| {
1668 arc.lock().ok().and_then(|slot| {
1669 let pk = slot.locator_to_peer.get(target).copied()?;
1670 // An EXPLICITLY negotiated per-reader level is respected: a
1671 // legacy-v1.4 reader has reader_protection=None and MUST get plaintext,
1672 // otherwise it cannot decode (heterogeneous domain). Only
1673 // when NO entry exists (matched via plaintext discovery, but
1674 // no level negotiated -> potentially unauthenticated) does the
1675 // governance data_protection FLOOR apply as leak protection.
1676 // Governance data_protection is a FLOOR (§8.4.2.4, memory-documented):
1677 // a per-reader level can only STRENGTHEN, never fall below the domain
1678 // policy. A reader discovered via secure SEDP whose security_info parses
1679 // to `Some(None)` (no is_payload_protected bit detected, discovery=
1680 // ENCRYPT) would otherwise yield level=None -> Some(None) arm -> PLAINTEXT
1681 // leak, although the domain requires data_protection=ENCRYPT (disc-data-
1682 // enc: zerodds sent user DATA without the N-flag -> OpenDDS decode_serialized_
1683 // payload=0 -> no echo). `.max` enforces at least the governance FLOOR.
1684 // With gov=None legacy plaintext (reader_lv) stays allowed.
1685 let level = match slot.reader_protection.get(&pk).copied() {
1686 Some(reader_lv) => reader_lv.max(gov_data_level),
1687 None => gov_data_level,
1688 };
1689 Some((pk, level))
1690 })
1691 });
1692 match resolved {
1693 // Matched reader with Sign/Encrypt: cyclone-conformant SUBMESSAGE
1694 // protection (SEC_PREFIX/BODY/POSTFIX around the DATA submessage, local
1695 // data key) instead of message-level SRTPS — `metadata_protection_kind=
1696 // ENCRYPT`, §9.5.3.3. cyclone decodes with the key sent via datawriter_crypto_
1697 // tokens. `None` level = byte-identical passthrough.
1698 Some((peer_key, level)) if level != ProtectionLevel::None => {
1699 // Layer choice per governance (DDS-Security §8.4.2.4 vs §7.3.7):
1700 // * metadata_protection_kind != NONE -> per-submessage protection
1701 // (`encode_datawriter_submessage`, SEC_PREFIX/BODY/POSTFIX) for
1702 // EVERY writer submessage (DATA, HEARTBEAT, GAP, ...). This is the
1703 // cyclone interop path: cyclone expects HEARTBEAT/GAP SEC_*-
1704 // wrapped too, otherwise its reader never NACKs (no reliable recovery).
1705 // * otherwise (only rtps_protection_kind != NONE) -> message-level SRTPS
1706 // via `transform_outbound_for` (whole message, §7.3.7).
1707 // INNER layer (§9.5.3.3.1): data_protection encrypts ONLY the
1708 // SerializedPayload of each DATA submessage. Applied BEFORE the outer
1709 // submessage/message layer — cyclone-conformant
1710 // nesting (§9.5.3.3): data_protection (inner) + metadata_
1711 // protection (outer). With pure data_protection this is the
1712 // only + complete protection.
1713 let inner: Vec<u8> = if gov_data_level != ProtectionLevel::None {
1714 // Crypto error -> drop instead of leak (None propagated via `?`).
1715 protect_user_payload(rt, bytes)?
1716 } else {
1717 bytes.to_vec()
1718 };
1719 // OUTER layer choice (DDS-Security §8.4.2.4 / §7.3.7):
1720 if gate.metadata_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None
1721 {
1722 // metadata_protection -> per-submessage protection (DATA, HEARTBEAT,
1723 // GAP, ...) with the per-endpoint writer key (cyclone interop path).
1724 // Under additional rtps_protection message-level SRTPS MUST go on top
1725 // (both layers) — otherwise "clear submsg from protected src".
1726 match protect_user_datagram(rt, &inner) {
1727 Some(ms)
1728 if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1729 != ProtectionLevel::None =>
1730 {
1731 gate.transform_outbound(&ms).ok()
1732 }
1733 other => other,
1734 }
1735 } else if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
1736 != ProtectionLevel::None
1737 {
1738 // rtps_protection -> message-level SRTPS (whole message, §7.3.7),
1739 // per-reader key.
1740 gate.transform_outbound_for(&peer_key, &inner, level).ok()
1741 } else {
1742 // only data_protection -> the payload layer is already the
1743 // complete protection (§9.5.3.3.1). Header/InlineQoS stay
1744 // plaintext, the encrypted payload carries the N-flag.
1745 Some(inner)
1746 }
1747 }
1748 // Matched reader with level None: a legacy-v1.4 reader (explicit
1749 // SEDP legacy or NONE governance) gets byte-identical plaintext —
1750 // message-level SRTPS would make it undecryptable.
1751 Some(_) => {
1752 // Matched reader with data level None: under rtps_protection the
1753 // message MUST still be message-level-SRTPS-wrapped (§8.4.2.4) —
1754 // the data_protection level only controls the payload/submessage layer.
1755 // Without it user DATA/HEARTBEAT leaks plain, although the domain
1756 // requires rtps_protection=ENCRYPT (the peer discards it as legacy).
1757 if gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None {
1758 gate.transform_outbound(bytes).ok()
1759 } else {
1760 Some(bytes.to_vec())
1761 }
1762 }
1763 // No locator-resolved reader: multicast/meta bootstrap OR a
1764 // user reader whose locator is (not yet) in `locator_to_peer`
1765 // (e.g. discovered via secure SEDP, discovery_protection=ENCRYPT). The
1766 // data_protection (inner payload layer §9.5.3.3.1) is TARGET-INDEPENDENT
1767 // (local writer key) and MUST still apply for a user writer —
1768 // otherwise under data_protection=ENCRYPT the user DATA leaks PLAINTEXT (N-flag
1769 // missing -> a spec-conformant remote reader never calls `decode_serialized_payload`
1770 // -> no sample, no echo; disc-data-enc stall, source-documented: OpenDDS
1771 // decode_serialized_payload=0). ONLY for user writers — SPDP/SEDP builtin DATA
1772 // must bootstrap plaintext (otherwise undecodable before key exchange).
1773 None => {
1774 use zerodds_rtps::wire_types::EntityKind;
1775 let is_user_writer = matches!(
1776 writer_eid.entity_kind,
1777 EntityKind::UserWriterWithKey | EntityKind::UserWriterNoKey
1778 );
1779 if is_user_writer && gov_data_level != ProtectionLevel::None {
1780 let inner = protect_user_payload(rt, bytes)?;
1781 gate.transform_outbound(&inner).ok()
1782 } else {
1783 gate.transform_outbound(bytes).ok()
1784 }
1785 }
1786 }
1787}
1788
1789#[cfg(not(feature = "security"))]
1790fn secure_outbound_for_target(
1791 _rt: &DcpsRuntime,
1792 _writer_eid: EntityId,
1793 bytes: &[u8],
1794 _target: &Locator,
1795) -> Option<Vec<u8>> {
1796 Some(bytes.to_vec())
1797}
1798
1799/// FU2 S3: data_protection-aware user DATA outbound. Encrypts the
1800/// datagram with the governance `data_protection` level. `transform_outbound_
1801/// for` ignores the `peer_key` and uses the local key — the ciphertext
1802/// is decryptable for EVERY authenticated peer (with our token),
1803/// non-authenticated peers cannot read it. A `None` level falls
1804/// back to message-level (`rtps_protection` resp. passthrough). Used for
1805/// UDP + in-process fastpath + SHM UNIFORMLY, so the
1806/// inproc path is secured too.
1807#[cfg(feature = "security")]
1808fn secure_user_outbound<'a>(
1809 rt: &DcpsRuntime,
1810 bytes: &'a [u8],
1811) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1812 let Some(gate) = &rt.config.security else {
1813 return Some(alloc::borrow::Cow::Borrowed(bytes));
1814 };
1815 let level = gate.data_protection().unwrap_or(ProtectionLevel::None);
1816 if matches!(level, ProtectionLevel::None) {
1817 gate.transform_outbound(bytes)
1818 .ok()
1819 .map(alloc::borrow::Cow::Owned)
1820 } else {
1821 gate.transform_outbound_for(&[0u8; 12], bytes, level)
1822 .ok()
1823 .map(alloc::borrow::Cow::Owned)
1824 }
1825}
1826
1827#[cfg(not(feature = "security"))]
1828fn secure_user_outbound<'a>(
1829 _rt: &DcpsRuntime,
1830 bytes: &'a [u8],
1831) -> Option<alloc::borrow::Cow<'a, [u8]>> {
1832 Some(alloc::borrow::Cow::Borrowed(bytes))
1833}
1834
1835/// Sends `bytes` to `target` on the matching interface socket.
1836/// Falls back to `rt.user_unicast` if no
1837/// pool is configured or no binding matches the target range
1838/// and no default binding is set either.
1839#[cfg(feature = "security")]
1840fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1841 if let Some(pool) = &rt.outbound_pool {
1842 if let Some((socket, _iface)) = pool.route(target) {
1843 let _ = socket.send(target, bytes);
1844 return;
1845 }
1846 }
1847 let _ = rt.user_unicast.send(target, bytes);
1848}
1849
1850#[cfg(not(feature = "security"))]
1851fn send_on_best_interface(rt: &DcpsRuntime, target: &Locator, bytes: &[u8]) {
1852 let _ = rt.user_unicast.send(target, bytes);
1853}
1854
1855/// User-writer slot in the runtime. Carries ReliableWriter + topic meta +
1856/// fragment size (from QoS).
1857struct UserWriterSlot {
1858 writer: ReliableWriter,
1859 topic_name: String,
1860 type_name: String,
1861 reliable: bool,
1862 durability: zerodds_qos::DurabilityKind,
1863 /// Deadline period in nanoseconds (0 == INFINITE, no monitoring).
1864 deadline_nanos: u64,
1865 /// Last successful `write` relative to `DcpsRuntime::start_instant`.
1866 last_write: Option<Duration>,
1867 /// Counter for missed deadlines (Spec §2.2.4.2.9).
1868 offered_deadline_missed_count: u64,
1869 /// Counter for LivelinessLost detections from the writer's view
1870 /// (Spec §2.2.4.2.10). Incremented in `check_writer_liveliness` on
1871 /// manual-lease overrun. 0 == not monitored.
1872 liveliness_lost_count: u64,
1873 /// Last assert time (manual liveliness). `None` == never.
1874 last_liveliness_assert: Option<Duration>,
1875 /// Per-policy counter for offered_incompatible_qos. Spec
1876 /// §2.2.4.2.4.2 — writer side. Incremented on
1877 /// `wire_writer_to_remote_reader` reject.
1878 offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus,
1879 /// Lifespan duration in nanoseconds (0 == INFINITE, no expiry).
1880 lifespan_nanos: u64,
1881 /// Per sample SN the insert time (relative to start_instant).
1882 /// Removed from front on expiry — SNs are monotonic, lifespan
1883 /// is constant, so the expiry prefix is always front.
1884 sample_insert_times:
1885 alloc::collections::VecDeque<(zerodds_rtps::wire_types::SequenceNumber, Duration)>,
1886 /// Liveliness kind (Automatic / ManualByParticipant / ManualByTopic).
1887 liveliness_kind: zerodds_qos::LivelinessKind,
1888 /// Lease duration in nanoseconds (0 == INFINITE).
1889 liveliness_lease_nanos: u64,
1890 /// Ownership mode.
1891 ownership: zerodds_qos::OwnershipKind,
1892 /// Ownership strength (Spec §2.2.3.2). Mirrored in the same-runtime
1893 /// dispatch into `UserSample::Alive.writer_strength`, so that
1894 /// EXCLUSIVE ownership logic in the reader also works for intra-process
1895 /// loopback.
1896 ownership_strength: i32,
1897 /// Partition list.
1898 partition: Vec<String>,
1899 /// Per-matched-reader ProtectionLevel. Derived at the
1900 /// SEDP match from `sub.security_info`. `None` entries
1901 /// for legacy readers. Empty for writers without matched
1902 /// security peers — then the hot path is unchanged.
1903 #[cfg(feature = "security")]
1904 reader_protection: BTreeMap<[u8; 12], ProtectionLevel>,
1905 /// Mapping Locator → GuidPrefix for the writer tick loop, so that
1906 /// `secure_outbound_for_target` can look up the protection per target
1907 /// without breaking the writer-tick API (`dg.targets` are
1908 /// locator lists today).
1909 #[cfg(feature = "security")]
1910 locator_to_peer: BTreeMap<Locator, [u8; 12]>,
1911 /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the writer type
1912 /// (from `T::TYPE_IDENTIFIER` in `UserWriterConfig`).
1913 type_identifier: zerodds_types::TypeIdentifier,
1914 /// D.5g — per-writer override for the DataRepresentation offer.
1915 /// `None` = runtime default. `Some(vec)` = hardcoded per writer.
1916 data_rep_offer_override: Option<Vec<i16>>,
1917 /// Type extensibility of the writer type (FINAL/APPENDABLE/MUTABLE).
1918 /// Together with the offer `first` element it determines the
1919 /// encapsulation header of the user payload (see
1920 /// [`user_payload_encap`]). Default `Final`; set by codegen/FFI via
1921 /// `set_user_writer_wire_extensibility` when the type
1922 /// is appendable/mutable (relevant for XCDR2 wire: D_CDR2/PL_CDR2).
1923 wire_extensibility: zerodds_types::qos::ExtensibilityForRepr,
1924 /// Spec §2.2.3.5 DurabilityService — with Durability=Transient/
1925 /// Persistent the backend holds in addition to the writer's own
1926 /// HistoryCache. On the first late-joiner match in
1927 /// `wire_writer_to_remote_reader` the backend samples are
1928 /// (re-)injected into the HistoryCache, so that the RTPS reliable
1929 /// path delivers them to the reader. `None` for Volatile/
1930 /// TransientLocal (the cache suffices).
1931 durability_backend: Option<alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>>,
1932 /// `true` as soon as the backend has been replayed once into the
1933 /// HistoryCache. Prevents repeated re-injection on further matches.
1934 backend_primed: bool,
1935}
1936
1937/// The listener dispatch carries, alongside the `UserSample`, a
1938/// zero-copy view on the original `Arc<[u8]>` with an encap offset
1939/// (lever-E zero-copy path).
1940pub type UserSampleWithEncap = (UserSample, Option<(Arc<[u8]>, usize)>);
1941
1942/// Sample channel item: either data payload or lifecycle marker.
1943/// Lifecycle is reconstructed by the wire path as `key_hash + ChangeKind` from
1944/// the PID_STATUS_INFO header; the DataReader DCPS layer
1945/// translates that into `__push_lifecycle`.
1946#[derive(Debug, Clone)]
1947pub enum UserSample {
1948 /// Normal sample with payload (CDR-encoded application type).
1949 /// `writer_guid` is the 16-byte GUID of the emitting writer
1950 /// — needed by the subscriber for exclusive-ownership resolution
1951 /// (DDS 1.4 §2.2.3.23 / §2.2.2.5.5).
1952 Alive {
1953 /// CDR payload (without encapsulation header). Zero-copy container:
1954 /// typically holds an `Arc<[u8]>` slice into the RTPS wire datagram
1955 /// without a heap re-alloc. See `docs/specs/zerodds-zero-copy-1.0.md`.
1956 payload: crate::sample_bytes::SampleBytes,
1957 /// Writer GUID — for strongest-writer selection.
1958 writer_guid: [u8; 16],
1959 /// Writer `ownership_strength` at the time of receipt.
1960 /// `0` if the writer is not yet known via discovery
1961 /// (the reader treats this as default strength = spec-conformant
1962 /// for shared-ownership topics; for exclusive the
1963 /// reader filters the real strength against the current owner).
1964 writer_strength: i32,
1965 /// XCDR version of the `payload` — extracted from the encapsulation
1966 /// header of the wire sample (RTPS 2.5 §10.5) BEFORE the
1967 /// header was stripped: `0` = XCDR1 (CDR/PL_CDR), `1` =
1968 /// XCDR2 (CDR2/D_CDR2/PL_CDR2). The typed consumer
1969 /// needs this to decode the body with the correct alignment rule
1970 /// (XTypes 1.3 §7.4.3.4.2).
1971 representation: u8,
1972 },
1973 /// Lifecycle marker (dispose / unregister) — the reader sets
1974 /// InstanceState accordingly.
1975 Lifecycle {
1976 /// Key hash of the affected instance (16 byte).
1977 key_hash: [u8; 16],
1978 /// `NotAliveDisposed` / `NotAliveUnregistered` /
1979 /// `NotAliveDisposedUnregistered`.
1980 kind: zerodds_rtps::history_cache::ChangeKind,
1981 },
1982}
1983
1984/// User-reader slot. ReliableReader + topic meta + channel to the
1985/// DataReader (DCPS API side).
1986/// Listener callback for sample arrival.
1987///
1988/// Fired synchronously by `recv_user_data_loop` in the recv-thread
1989/// context as soon as an alive sample lands in the reader HistoryCache.
1990/// Eliminates the polling latency of `zerodds_reader_take()` —
1991/// the listener path typically saves 50-100 µs per side.
1992///
1993/// **Contract** (analogous to DDS spec §2.2.4.4 listener semantics):
1994/// * The callback runs on the recv thread, NOT the user thread.
1995/// * Short and non-blocking. No I/O, no locks, no
1996/// ZeroDDS API calls inside.
1997/// * `bytes` points to the CDR payload of the alive sample (without
1998/// encapsulation header). Lifetime only for the duration of the
1999/// callback; copy if needed beyond the call.
2000/// * Disposed/unregistered lifecycle events do NOT fire the listener
2001/// (only `Alive` samples) — for lifecycle tracking
2002/// keep using `zerodds_reader_take()` or add a
2003/// lifecycle-listener API.
2004///
2005/// Data-available listener. Arguments: CDR body (without encapsulation
2006/// header) and the XCDR version of the sample (`0` = XCDR1, `1` = XCDR2)
2007/// — the typed consumer needs the latter for the alignment
2008/// rule on decode (XTypes 1.3 §7.4.3.4.2).
2009pub type UserReaderListener = alloc::boxed::Box<dyn Fn(&[u8], u8) + Send + Sync + 'static>;
2010
2011struct UserReaderSlot {
2012 reader: ReliableReader,
2013 topic_name: String,
2014 type_name: String,
2015 sample_tx: mpsc::Sender<UserSample>,
2016 /// Spec §3 zerodds-async-1.0: async waker slot. Registered by the
2017 /// async reader; on `sample_tx.send` we call
2018 /// `waker.wake()`. `None` if no async reader is active.
2019 async_waker: alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>,
2020 /// Listener callback for alive samples.
2021 /// Fired synchronously by `recv_user_data_loop`. `None` =
2022 /// no listener registered (the user polls via
2023 /// `zerodds_reader_take()`). Arc, so the recv thread can
2024 /// execute the callback cloned without another lock (minimize lock
2025 /// hold time).
2026 listener: Option<alloc::sync::Arc<UserReaderListener>>,
2027 durability: zerodds_qos::DurabilityKind,
2028 /// Deadline period in nanoseconds (0 == INFINITE).
2029 deadline_nanos: u64,
2030 /// Time of the last received sample relative to runtime start.
2031 last_sample_received: Option<Duration>,
2032 /// Counter for missed deadline expectations (Spec §2.2.4.2.11).
2033 requested_deadline_missed_count: u64,
2034 /// Per-policy counter for requested_incompatible_qos. Spec
2035 /// §2.2.4.2.6.5 — reader side. Incremented on
2036 /// `wire_reader_to_remote_writer` reject.
2037 requested_incompatible_qos: crate::status::RequestedIncompatibleQosStatus,
2038 /// Sample-lost counter (Spec §2.2.4.2.6.2). Incremented
2039 /// by `record_sample_lost`.
2040 sample_lost_count: u64,
2041 /// Sample-rejected counter (Spec §2.2.4.2.6.3). Incremented
2042 /// by `record_sample_rejected`.
2043 sample_rejected: crate::status::SampleRejectedStatus,
2044 /// Monotonically increasing count of alive samples delivered to the
2045 /// user. Serves as a non-consuming data-availability detector for
2046 /// `on_data_available` (DDS 1.4 §2.2.4.2.6.1) — unlike
2047 /// `last_sample_received`, this counter is only bumped on real sample
2048 /// delivery, never by the deadline path. Read via
2049 /// [`DcpsRuntime::user_reader_samples_delivered`].
2050 samples_delivered_count: u64,
2051 /// Reader-side requested liveliness lease (0 == INFINITE).
2052 liveliness_lease_nanos: u64,
2053 /// Reader-side requested liveliness kind.
2054 liveliness_kind: zerodds_qos::LivelinessKind,
2055 /// Counter: how often the writer was marked "alive"
2056 /// (Spec §2.2.4.2.14 alive_count).
2057 liveliness_alive_count: u64,
2058 /// Counter: how often it was marked "not_alive" (lease expired).
2059 liveliness_not_alive_count: u64,
2060 /// Current "alive/not-alive" state from the reader's view.
2061 liveliness_alive: bool,
2062 /// Ownership.
2063 ownership: zerodds_qos::OwnershipKind,
2064 /// Partition.
2065 partition: Vec<String>,
2066 /// Per-writer strength cache for exclusive-ownership resolution
2067 /// (DDS 1.4 §2.2.3.23). Filled by `wire_reader_to_remote_writer`
2068 /// from each `PublicationBuiltinTopicData.ownership_strength`;
2069 /// `delivered_to_user_sample` looks it up here to pack the
2070 /// strength into `UserSample::Alive`.
2071 writer_strengths: alloc::collections::BTreeMap<[u8; 16], i32>,
2072 /// F-TYPES-3 XTypes 1.3 §7.3.4.2 TypeIdentifier of the reader type
2073 /// (from `T::TYPE_IDENTIFIER` in `UserReaderConfig`). Default
2074 /// `TypeIdentifier::None` signals "no TypeIdentifier" —
2075 /// the match falls back to a pure `type_name` comparison
2076 /// (DDS 1.4 §2.2.3 default path).
2077 type_identifier: zerodds_types::TypeIdentifier,
2078 /// XTypes 1.3 §7.6.3.7 — TCE policy controlling the strictness
2079 /// of the XTypes match path.
2080 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2081}
2082
2083/// Helper struct for announcing a local publication/subscription
2084/// as SEDP BuiltinTopicData. The caller creates it once per
2085/// writer/reader registration and passes it to SedpStack.
2086/// QoS config for registering a user writer with the runtime.
2087/// Bundles all policies that go on the wire via SEDP plus the local
2088/// Per-endpoint discovery info for ROS 2 endpoint-info-by-topic introspection
2089/// (`rmw_get_publishers_info_by_topic` / `rmw_get_subscriptions_info_by_topic`,
2090/// the data behind `ros2 topic info -v`). Covers local user endpoints plus
2091/// remote SEDP-discovered ones. QoS is best-effort from what discovery carries
2092/// (history/depth are not on the wire, so the consumer fills rmw defaults).
2093#[derive(Debug, Clone)]
2094pub struct DiscoveredEndpointInfo {
2095 /// DDS topic name (raw, un-demangled).
2096 pub topic_name: String,
2097 /// IDL type name (raw).
2098 pub type_name: String,
2099 /// 16-byte endpoint GUID: 12-byte participant prefix + 4-byte entity id.
2100 /// Bytes 0..12 identify the owning participant (for node-name lookup).
2101 pub endpoint_guid: [u8; 16],
2102 /// RELIABLE (`true`) vs BEST_EFFORT (`false`).
2103 pub reliable: bool,
2104 /// TRANSIENT_LOCAL or stronger (`true`) vs VOLATILE (`false`).
2105 pub transient_local: bool,
2106 /// Deadline period in whole seconds (0 == INFINITE).
2107 pub deadline_seconds: i32,
2108 /// Lifespan in whole seconds (0 == INFINITE; always 0 for subscriptions).
2109 pub lifespan_seconds: i32,
2110 /// Liveliness lease in whole seconds (0 == INFINITE).
2111 pub liveliness_lease_seconds: i32,
2112}
2113
2114/// Packs an RTPS [`Guid`] into the 16-byte wire form (prefix ++ entity id).
2115fn guid_to_16(g: Guid) -> [u8; 16] {
2116 let mut b = [0u8; 16];
2117 b[..12].copy_from_slice(&g.prefix.to_bytes());
2118 b[12..].copy_from_slice(&g.entity_id.to_bytes());
2119 b
2120}
2121
2122/// monitoring. Avoids 10+-argument functions.
2123#[derive(Debug, Clone)]
2124pub struct UserWriterConfig {
2125 /// Topic name (DDS topic).
2126 pub topic_name: String,
2127 /// IDL type name.
2128 pub type_name: String,
2129 /// `true` = RELIABLE, `false` = BEST_EFFORT.
2130 pub reliable: bool,
2131 /// Durability.
2132 pub durability: zerodds_qos::DurabilityKind,
2133 /// Deadline period (offered).
2134 pub deadline: zerodds_qos::DeadlineQosPolicy,
2135 /// Lifespan duration (writer-only).
2136 pub lifespan: zerodds_qos::LifespanQosPolicy,
2137 /// Liveliness (offered).
2138 pub liveliness: zerodds_qos::LivelinessQosPolicy,
2139 /// Ownership mode (Shared / Exclusive).
2140 pub ownership: zerodds_qos::OwnershipKind,
2141 /// Strength for Exclusive (ignored for Shared).
2142 pub ownership_strength: i32,
2143 /// Partition list. Empty == default partition (`""`).
2144 pub partition: Vec<String>,
2145 /// UserData QoS (Spec §2.2.3.1) — opaque `sequence<octet>`, propagated
2146 /// via discovery.
2147 pub user_data: Vec<u8>,
2148 /// TopicData QoS (Spec §2.2.3.3).
2149 pub topic_data: Vec<u8>,
2150 /// GroupData QoS (Spec §2.2.3.2).
2151 pub group_data: Vec<u8>,
2152 /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up). Default
2153 /// `TypeIdentifier::None` for the `T::TYPE_IDENTIFIER` default.
2154 pub type_identifier: zerodds_types::TypeIdentifier,
2155
2156 /// D.5g — per-writer override of the DataRepresentation offer list.
2157 /// `None` = use `RuntimeConfig::data_representation_offer`.
2158 /// `Some(vec)` = overridden per writer (e.g. `[XCDR2]` for
2159 /// a modern-only pub).
2160 pub data_representation_offer: Option<Vec<i16>>,
2161}
2162
2163/// QoS config for registering a user reader.
2164#[derive(Debug, Clone)]
2165pub struct UserReaderConfig {
2166 /// Topic name.
2167 pub topic_name: String,
2168 /// IDL type name.
2169 pub type_name: String,
2170 /// `true` = RELIABLE, `false` = BEST_EFFORT.
2171 pub reliable: bool,
2172 /// Durability (requested).
2173 pub durability: zerodds_qos::DurabilityKind,
2174 /// Deadline (requested).
2175 pub deadline: zerodds_qos::DeadlineQosPolicy,
2176 /// Liveliness (requested).
2177 pub liveliness: zerodds_qos::LivelinessQosPolicy,
2178 /// Ownership.
2179 pub ownership: zerodds_qos::OwnershipKind,
2180 /// Partition.
2181 pub partition: Vec<String>,
2182 /// UserData QoS (Spec §2.2.3.1).
2183 pub user_data: Vec<u8>,
2184 /// TopicData QoS (Spec §2.2.3.3).
2185 pub topic_data: Vec<u8>,
2186 /// GroupData QoS (Spec §2.2.3.2).
2187 pub group_data: Vec<u8>,
2188 /// XTypes 1.3 §7.3.4.2 TypeIdentifier (F-TYPES-3 wire-up).
2189 pub type_identifier: zerodds_types::TypeIdentifier,
2190 /// TypeConsistencyEnforcement (XTypes §7.6.3.7) — controls how strictly
2191 /// the reader match checks XTypes compatibility.
2192 pub type_consistency: zerodds_types::qos::TypeConsistencyEnforcement,
2193
2194 /// D.5g — per-reader override of the DataRepresentation accept list.
2195 /// `None` = use `RuntimeConfig::data_representation_offer`.
2196 /// `Some(vec)` = overridden per reader (e.g. `[XCDR1]` for
2197 /// a reader that accepts only legacy XCDR1 wire).
2198 pub data_representation_offer: Option<Vec<i16>>,
2199}
2200
2201fn build_publication_data(
2202 owner_prefix: GuidPrefix,
2203 writer_eid: EntityId,
2204 cfg: &UserWriterConfig,
2205 runtime_offer: &[i16],
2206 user_locator: Locator,
2207) -> zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2208 use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2209 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
2210 key: Guid::new(owner_prefix, writer_eid),
2211 participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2212 topic_name: cfg.topic_name.clone(),
2213 type_name: cfg.type_name.clone(),
2214 durability: cfg.durability,
2215 reliability: ReliabilityQosPolicy {
2216 kind: if cfg.reliable {
2217 ReliabilityKind::Reliable
2218 } else {
2219 ReliabilityKind::BestEffort
2220 },
2221 max_blocking_time: QosDuration::from_millis(100_i32),
2222 },
2223 ownership: cfg.ownership,
2224 ownership_strength: cfg.ownership_strength,
2225 liveliness: cfg.liveliness,
2226 deadline: cfg.deadline,
2227 lifespan: cfg.lifespan,
2228 partition: cfg.partition.clone(),
2229 user_data: cfg.user_data.clone(),
2230 topic_data: cfg.topic_data.clone(),
2231 group_data: cfg.group_data.clone(),
2232 type_information: None,
2233 // D.5g — PID_DATA_REPRESENTATION (XTypes 1.3 §7.6.3.1.1, RTPS 2.5
2234 // PID 0x0073). Per-Writer-Override (cfg.data_representation_offer)
2235 // overrides the RuntimeConfig default.
2236 data_representation: cfg
2237 .data_representation_offer
2238 .clone()
2239 .unwrap_or_else(|| runtime_offer.to_vec()),
2240 // Security: the PolicyEngine fills this later. Default
2241 // None = legacy behavior (no EndpointSecurityInfo PID).
2242 security_info: None,
2243 // .B — RPC discovery PIDs. Default None: no RPC endpoint;
2244 // the RpcEndpoint builder fills these fields.
2245 service_instance_name: None,
2246 related_entity_guid: None,
2247 topic_aliases: None,
2248 // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2249 type_identifier: cfg.type_identifier.clone(),
2250 // DDSI-RTPS 2.5 §8.5.3.3: endpoint locator. All user endpoints
2251 // share the one `user_unicast` socket — hence the
2252 // endpoint locator equals the resolved participant locator.
2253 unicast_locators: alloc::vec![user_locator],
2254 multicast_locators: Vec::new(),
2255 }
2256}
2257
2258fn build_subscription_data(
2259 owner_prefix: GuidPrefix,
2260 reader_eid: EntityId,
2261 cfg: &UserReaderConfig,
2262 runtime_offer: &[i16],
2263 user_locator: Locator,
2264) -> zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2265 use zerodds_qos::{ReliabilityKind, ReliabilityQosPolicy};
2266 zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
2267 key: Guid::new(owner_prefix, reader_eid),
2268 participant_key: Guid::new(owner_prefix, EntityId::PARTICIPANT),
2269 topic_name: cfg.topic_name.clone(),
2270 type_name: cfg.type_name.clone(),
2271 durability: cfg.durability,
2272 reliability: ReliabilityQosPolicy {
2273 kind: if cfg.reliable {
2274 ReliabilityKind::Reliable
2275 } else {
2276 ReliabilityKind::BestEffort
2277 },
2278 max_blocking_time: QosDuration::from_millis(100_i32),
2279 },
2280 ownership: cfg.ownership,
2281 liveliness: cfg.liveliness,
2282 deadline: cfg.deadline,
2283 partition: cfg.partition.clone(),
2284 user_data: cfg.user_data.clone(),
2285 topic_data: cfg.topic_data.clone(),
2286 group_data: cfg.group_data.clone(),
2287 type_information: None,
2288 // D.5g — PID_DATA_REPRESENTATION (see build_publication_data).
2289 // A per-reader override overrides the RuntimeConfig default.
2290 data_representation: cfg
2291 .data_representation_offer
2292 .clone()
2293 .unwrap_or_else(|| runtime_offer.to_vec()),
2294 content_filter: None,
2295 security_info: None,
2296 service_instance_name: None,
2297 related_entity_guid: None,
2298 topic_aliases: None,
2299 // F-TYPES-3 Wire-up: XTypes-1.3 §7.3.4.2 TypeIdentifier.
2300 type_identifier: cfg.type_identifier.clone(),
2301 // DDSI-RTPS 2.5 §8.5.3.2: endpoint locator (see
2302 // build_publication_data).
2303 unicast_locators: alloc::vec![user_locator],
2304 multicast_locators: Vec::new(),
2305 }
2306}
2307
2308/// The runtime of a `DomainParticipant`. Hosts all background
2309/// threads and UDP sockets.
2310pub struct DcpsRuntime {
2311 /// Participant GUID prefix (12-byte identifier, random per instance).
2312 pub guid_prefix: GuidPrefix,
2313 /// Domain id.
2314 pub domain_id: i32,
2315 /// SPDP multicast receiver socket.
2316 pub spdp_multicast_rx: Arc<UdpTransport>,
2317 /// SPDP unicast socket (for bidirectional SPDP, B2).
2318 pub spdp_unicast: Arc<UdpTransport>,
2319 /// User-data unicast transport (default user unicast, where peers
2320 /// send matched samples). Trait object: can be UDP/v4 or /v6,
2321 /// and in phase C additionally TCP or SHM (env var
2322 /// `ZERODDS_USER_TRANSPORT`). Discovery (SPDP/SEDP) stays UDP-only.
2323 pub user_unicast: Arc<dyn Transport + Send + Sync>,
2324 /// Resolved user-unicast locator (routable interface address,
2325 /// not `0.0.0.0`). Written as `PID_UNICAST_LOCATOR` into EVERY
2326 /// SEDP pub/sub announce (DDSI-RTPS 2.5 §8.5.3.2/3)
2327 /// and as the participant `DEFAULT_UNICAST_LOCATOR` in SPDP. Precomputed
2328 /// via `announce_locator`, so the endpoint and participant locators
2329 /// are guaranteed identical.
2330 pub user_announce_locator: Locator,
2331 /// Sender socket for the SPDP multicast announce (separate UdpSocket
2332 /// without SO_REUSE/SO_BIND_IP_MULTICAST, so send_to routes cleanly).
2333 spdp_mc_tx: Arc<UdpTransport>,
2334 /// SPDP beacon (sends periodic announces).
2335 spdp_beacon: Mutex<SpdpBeacon>,
2336 /// Own participant data (SPDP self-view). Handed by the in-process
2337 /// discovery fastpath as a `DiscoveredParticipant` to same-process
2338 /// peers (see [`crate::inproc`]).
2339 participant_data: ParticipantBuiltinTopicData,
2340 /// Stash of all locally announced publications/subscriptions —
2341 /// so a peer runtime starting later in the same process
2342 /// can pull our endpoints via `inproc_snapshot`
2343 /// (pull-on-creation of the in-process discovery fastpath).
2344 /// Append-only; a future patch for endpoint deletion would
2345 /// remove by GUID here.
2346 announced_pubs: Mutex<Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>>,
2347 announced_subs: Mutex<Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>>,
2348 /// SPDP reader (parses incoming beacons).
2349 spdp_reader: SpdpReader,
2350 /// Discovered remote participants (prefix → data).
2351 discovered: Arc<Mutex<DiscoveredParticipantsCache>>,
2352 /// SEDP stack for publication/subscription announce + discovery.
2353 pub sedp: Arc<Mutex<SedpStack>>,
2354 /// TypeLookup-Service Builtin-Endpoint-GUIDs (XTypes 1.3 §7.6.3.3.4).
2355 pub type_lookup_endpoints: TypeLookupEndpoints,
2356 /// TypeLookup server (server-side handler over the local
2357 /// TypeRegistry).
2358 pub type_lookup_server: Arc<Mutex<TypeLookupServer>>,
2359 /// TypeLookup client (client-side correlation table for outstanding
2360 /// requests).
2361 pub type_lookup_client: Arc<Mutex<TypeLookupClient>>,
2362 /// Monotonically increasing sequence number of the TL_SVC_REPLY_WRITER. Reply DATA
2363 /// carry their OWN writer_sn (instead of echoing the request SN) — the
2364 /// correlation runs via PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2),
2365 /// so a reliable cross-vendor reply reader sees no SN jumps.
2366 tl_reply_sn: core::sync::atomic::AtomicU64,
2367 /// Security builtin endpoint stack
2368 /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
2369 /// MessageSecure`). `None` as long as no security plugin is active
2370 /// — the hot path then skips any security-builtin
2371 /// demux. `Some` is set via [`DcpsRuntime::enable_security_builtins`]
2372 /// as soon as the factory has registered a plugin.
2373 pub security_builtin: Mutex<Option<Arc<Mutex<SecurityBuiltinStack>>>>,
2374 /// Monotonic "start time" — for SEDP tick clocks.
2375 start_instant: Instant,
2376 /// Local user-writer registry (EntityId → writer state).
2377 user_writers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserWriterSlot>>>>>,
2378 /// ADR-0006 side map: per user writer an optional ShmLocator bytes
2379 /// value (PID_SHM_LOCATOR in the SEDP sample). `None` = no
2380 /// same-host backend attached. The wire encoder consults
2381 /// this map on the SEDP push.
2382 shm_locators: Arc<RwLock<BTreeMap<EntityId, Vec<u8>>>>,
2383 /// Wave 4 (Spec `zerodds-zero-copy-1.0` §6): tracker for
2384 /// same-host (writer, reader) pairs. The SEDP match hook registers
2385 /// here every pair whose remote prefix carries the same host-id prefix
2386 /// as the local participant. The hot-path send consults
2387 /// the tracker and routes over SHM instead of UDP in the `Bound` state.
2388 pub same_host: Arc<crate::same_host::SameHostTracker>,
2389 /// Local user-reader registry (EntityId → reader state).
2390 user_readers: Arc<RwLock<BTreeMap<EntityId, Arc<Mutex<UserReaderSlot>>>>>,
2391 /// Cross-vendor step 6b: peers to whom we have already sent per-endpoint
2392 /// crypto tokens (datawriter/datareader). Prevents spam on the
2393 /// repeated receipt of cyclone's tokens; sending happens only once the
2394 /// user endpoints exist (the bench creates them after handshake start).
2395 #[cfg(feature = "security")]
2396 /// Already-sent per-endpoint crypto tokens, per dedup key
2397 /// (source_endpoint ++ destination_endpoint, see `endpoint_token_key`).
2398 /// Per-token instead of per-peer, so late-matched user endpoints still
2399 /// get their tokens (#29).
2400 endpoint_tokens_sent: Arc<RwLock<alloc::collections::BTreeSet<[u8; 32]>>>,
2401 /// Peers (prefix) to whom our SEDP endpoint records have already been
2402 /// re-announced after a completed crypto-token exchange. Under rtps_/discovery_
2403 /// protection the initial SEDP burst is discarded by the peer (no key), until
2404 /// the participant crypto token arrives via Volatile; a one-time
2405 /// re-announce from that moment (the peer can now decode) brings the
2406 /// dropped SEDP up (OpenDDS flow; cyclone/FastDDS converge anyway).
2407 #[cfg(feature = "security")]
2408 sedp_reannounced: Arc<RwLock<alloc::collections::BTreeSet<[u8; 12]>>>,
2409 /// Per-endpoint crypto (DDS-Security §9.5.3.3): per local writer/reader
2410 /// EntityId its own crypto slot handle (its own key material, not the
2411 /// participant key). Used for the per-endpoint token (prepare_endpoint_
2412 /// crypto_tokens) AND the per-endpoint encode (protect_user_datagram)
2413 /// — the same key on both sides. Get-or-register lazily via
2414 /// `local_endpoint_crypto_handle`.
2415 #[cfg(feature = "security")]
2416 endpoint_crypto:
2417 Arc<RwLock<alloc::collections::BTreeMap<EntityId, zerodds_security::crypto::CryptoHandle>>>,
2418 /// Same-runtime writer→reader routes: per local writer the list
2419 /// of local readers subscribed to the same topic+type.
2420 /// Rebuilt in `recompute_intra_runtime_routes` on every
2421 /// register/unregister. Looked up in the write hot path,
2422 /// to push samples directly into the reader slot's `sample_tx`
2423 /// (intra-process loopback without an RTPS roundtrip, in parallel to the
2424 /// inproc peer path that only serves cross-runtime peers).
2425 intra_runtime_routes: Arc<RwLock<BTreeMap<EntityId, Vec<EntityId>>>>,
2426 /// Entity key counter (3 byte, incrementing). User writers use
2427 /// `0xC2` (with-key, user), user readers `0xC7`.
2428 entity_counter: AtomicU32,
2429 /// Configuration (cloned from RuntimeConfig).
2430 pub config: RuntimeConfig,
2431 /// Per-interface outbound socket pool. `None`
2432 /// when `config.interface_bindings` is empty — then
2433 /// `user_unicast` stays the only outbound socket (v1.4 path).
2434 #[cfg(feature = "security")]
2435 outbound_pool: Option<Arc<OutboundSocketPool>>,
2436 /// Writer-Liveliness-Protocol endpoint (RTPS 2.5 §8.4.13).
2437 /// Sends periodic `ParticipantMessageData` heartbeats and
2438 /// tracks last-seen per remote participant.
2439 pub wlp: Arc<Mutex<crate::wlp::WlpEndpoint>>,
2440 /// Builtin-topic reader sinks (DDS 1.4 §2.2.5). Set by the
2441 /// `DomainParticipant` constructor via `attach_builtin_sinks`;
2442 /// before that this is `None` and the discovery hot path
2443 /// drops samples silently (e.g. when the runtime is
2444 /// started directly for internal tests, without a participant).
2445 builtin_sinks: Mutex<Option<crate::builtin_subscriber::BuiltinSinks>>,
2446 /// Ignore filter (DDS 1.4 §2.2.2.2.1.14-17). Set by the
2447 /// `DomainParticipant` constructor via `attach_ignore_filter`.
2448 /// `None` means: no participant hook → no
2449 /// filtering.
2450 ignore_filter: Mutex<Option<crate::participant::IgnoreFilter>>,
2451 /// Stop flag for all worker threads (recv loops + tick loop).
2452 stop: Arc<AtomicBool>,
2453 /// Monotonic count of completed tick iterations. Incremented once per
2454 /// [`run_tick_iteration`], regardless of whether the tick is driven by the
2455 /// internal `zdds-tick` thread or an external executor (zerodds-async-1.0
2456 /// §4 `spawn_in_tokio`). Diagnostic: a stalled count means the tick loop
2457 /// stopped advancing. Read via [`DcpsRuntime::tick_count`].
2458 tick_seq: AtomicU64,
2459 /// Total SPDP announces emitted (multicast + unicast fan-out count as one).
2460 /// Diagnostic for the C3 initial-announcement burst — a fresh, peer-less
2461 /// participant should advance this fast initially. Read via
2462 /// [`DcpsRuntime::spdp_announce_count`].
2463 spdp_announce_seq: AtomicU64,
2464 /// Inconsistent-topic counter (DDS 1.4 §2.2.4.2.4). Incremented when
2465 /// matching discovers a remote endpoint carrying the same `topic_name`
2466 /// but a differing `type_name` in the SEDP cache. Read via
2467 /// [`DcpsRuntime::inconsistent_topic_count`].
2468 inconsistent_topic_seq: AtomicU64,
2469 /// D.5e Phase 3 — wake handle for the event-driven scheduler tick. `Some`
2470 /// only when started with `scheduler_tick`. Recv loops + the write path call
2471 /// [`DcpsRuntime::raise_tick_wake`] to wake the worker immediately on new
2472 /// work (so HEARTBEAT/ACKNACK/HB processing does not wait for a deadline).
2473 tick_wake: Mutex<Option<crate::scheduler::SchedulerHandle<TickEvent>>>,
2474 /// Coalesces wake raises: many incoming datagrams collapse into one wake.
2475 tick_wake_pending: AtomicBool,
2476 /// Worker thread JoinHandles. Per-socket recv threads + tick thread,
2477 /// all terminated together via `stop` (Sprint D.5b — previously
2478 /// a single single-threaded `event_loop`).
2479 handles: Mutex<Vec<JoinHandle<()>>>,
2480 /// Match-event notifier (D.5e Phase-1 quick win). Notified by the
2481 /// SEDP match path after `add_reader_proxy` / `add_writer_proxy`;
2482 /// `wait_for_matched_*` parks on it instead of polling every 20 ms.
2483 /// The mutex content is only a lock anchor for the Condvar API; there is
2484 /// no state protected by it (the count is read independently
2485 /// via `user_*_matched_count`).
2486 match_event: Arc<(Mutex<()>, Condvar)>,
2487 /// Acknowledgments event notifier. Notified when a writer
2488 /// receives an ACKNACK that advances its acked-base.
2489 /// `wait_for_acknowledgments` parks on it instead of polling every 50 ms.
2490 ack_event: Arc<(Mutex<()>, Condvar)>,
2491}
2492
2493impl core::fmt::Debug for DcpsRuntime {
2494 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
2495 f.debug_struct("DcpsRuntime")
2496 .field("domain_id", &self.domain_id)
2497 .field("guid_prefix", &self.guid_prefix)
2498 .field("spdp_group", &self.config.spdp_multicast_group)
2499 .finish_non_exhaustive()
2500 }
2501}
2502
2503/// Type alias: Arc-shared slot handles from the per-slot mutex
2504/// architecture.
2505type WriterSlotArc = Arc<Mutex<UserWriterSlot>>;
2506type ReaderSlotArc = Arc<Mutex<UserReaderSlot>>;
2507
2508impl DcpsRuntime {
2509 // ========================================================================
2510 // --- Per-Slot-Mutex-Helpers
2511 //
2512 // The `user_writers`/`user_readers` registry is `RwLock<BTreeMap<EntityId,
2513 // Arc<Mutex<Slot>>>>`. Hot-path accesses take the read lock briefly, clone
2514 // the slot Arc and release the read lock before taking the per-slot mutex.
2515 // Parallel writes to **different** slots thereby run
2516 // without global contention.
2517 //
2518 // Slot creation/deletion takes the write lock; that is rare and
2519 // amortizes out.
2520 // ========================================================================
2521
2522 /// Returns the slot Arc for a user writer, if present.
2523 /// Hot-path form: a single read lock + Arc clone, no
2524 /// per-slot mutex. The caller takes the mutex itself.
2525 fn writer_slot(&self, eid: EntityId) -> Option<WriterSlotArc> {
2526 self.user_writers
2527 .read()
2528 .ok()
2529 .and_then(|w| w.get(&eid).cloned())
2530 }
2531
2532 /// Returns the slot Arc for a user reader, if present.
2533 fn reader_slot(&self, eid: EntityId) -> Option<ReaderSlotArc> {
2534 self.user_readers
2535 .read()
2536 .ok()
2537 .and_then(|r| r.get(&eid).cloned())
2538 }
2539
2540 /// Snapshot of all writer slots as `Vec<(EntityId, Arc)>`. Allows
2541 /// iteration without holding the registry read lock — e.g. for
2542 /// the heartbeat tick or liveliness sweep, where we potentially take every
2543 /// slot's mutex.
2544 fn writer_slots_snapshot(&self) -> Vec<(EntityId, WriterSlotArc)> {
2545 match self.user_writers.read() {
2546 Ok(w) => w.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2547 Err(_) => Vec::new(),
2548 }
2549 }
2550
2551 /// Snapshot of all reader slots — symmetric to writer_slots_snapshot.
2552 fn reader_slots_snapshot(&self) -> Vec<(EntityId, ReaderSlotArc)> {
2553 match self.user_readers.read() {
2554 Ok(r) => r.iter().map(|(k, v)| (*k, Arc::clone(v))).collect(),
2555 Err(_) => Vec::new(),
2556 }
2557 }
2558
2559 /// Returns the list of EntityIds of all registered writers.
2560 /// Very lightweight — no slot-Arc clone, just EntityIds.
2561 fn writer_eids(&self) -> Vec<EntityId> {
2562 match self.user_writers.read() {
2563 Ok(w) => w.keys().copied().collect(),
2564 Err(_) => Vec::new(),
2565 }
2566 }
2567
2568 /// Returns the list of EntityIds of all registered readers.
2569 fn reader_eids(&self) -> Vec<EntityId> {
2570 match self.user_readers.read() {
2571 Ok(r) => r.keys().copied().collect(),
2572 Err(_) => Vec::new(),
2573 }
2574 }
2575
2576 /// Starts a new runtime for a participant.
2577 ///
2578 /// # Errors
2579 /// `TransportError` if one of the 3 UDP sockets fails to bind
2580 /// (e.g. a port collision on the SPDP multicast port in another
2581 /// SO_REUSE-less DDS instance).
2582 pub fn start(
2583 domain_id: i32,
2584 guid_prefix: GuidPrefix,
2585 mut config: RuntimeConfig,
2586 ) -> Result<Arc<Self>> {
2587 // C1 multicast-free discovery: merge the domain-aware env `ZERODDS_PEERS`
2588 // into the (programmatic) `config.initial_peers`. Default
2589 // is both empty → pure multicast behavior.
2590 config
2591 .initial_peers
2592 .extend(parse_initial_peers_env(domain_id as u32));
2593 // SPDP multicast receiver on the spec port.
2594 // u32 → u16 enforcing, the spec port is always < 65536.
2595 let spdp_port = u16::try_from(spdp_multicast_port(domain_id as u32)).map_err(|_| {
2596 DdsError::BadParameter {
2597 what: "domain_id too large for SPDP port mapping",
2598 }
2599 })?;
2600 let spdp_mc = UdpTransport::bind_multicast_v4(
2601 config.spdp_multicast_group,
2602 spdp_port,
2603 config.multicast_interface,
2604 )
2605 .map_err(|_| DdsError::TransportError {
2606 label: "spdp multicast bind",
2607 })?
2608 // Sprint D.5b: recv sockets have their own thread that
2609 // blocks waiting for data. Timeout 1 s = stop-flag polling
2610 // granularity at shutdown, NOT the tick rhythm.
2611 .with_timeout(Some(Duration::from_secs(1)))
2612 .map_err(|_| DdsError::TransportError {
2613 label: "spdp multicast set_timeout",
2614 })?;
2615
2616 // SPDP unicast: bind to the **well-known** RTPS port
2617 // (7400+250*domain+10+2*pid, Spec §9.6.1.4.1), so a
2618 // configured unicast initial peer can reach this participant
2619 // WITHOUT prior multicast (C1 multicast-free
2620 // discovery). Participant index 0,1,2,… until a free port
2621 // is found (multiple participants per host, also alongside
2622 // Cyclone/FastDDS). Fallback ephemeral if all well-known
2623 // ports are taken (then multicast discovery only).
2624 // Interface pinning (ZERODDS_INTERFACE): UNSPECIFIED = auto. If
2625 // set, ALL IP sockets bind (SPDP-uc, SPDP-mc-tx, user UDP/TCP)
2626 // to this IP → announce + egress + receive on exactly this
2627 // interface (multi-homed robustness, cf. Cyclone `NetworkInterface`).
2628 let pinned = config.multicast_interface;
2629 let (spdp_uc_raw, _spdp_participant_id) = {
2630 let mut bound = None;
2631 for pid in 0u32..120 {
2632 let Ok(port) = u16::try_from(spdp_unicast_port(domain_id as u32, pid)) else {
2633 break;
2634 };
2635 if let Ok(sock) = UdpTransport::bind_v4(pinned, port) {
2636 bound = Some((sock, pid));
2637 break;
2638 }
2639 }
2640 match bound {
2641 Some(b) => b,
2642 None => (
2643 UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2644 label: "spdp unicast bind",
2645 })?,
2646 u32::MAX,
2647 ),
2648 }
2649 };
2650 let spdp_uc = spdp_uc_raw
2651 .with_timeout(Some(Duration::from_secs(1)))
2652 .map_err(|_| DdsError::TransportError {
2653 label: "spdp unicast set_timeout",
2654 })?;
2655
2656 // User-data unicast (ephemeral port). Transport choice primarily via
2657 // `RuntimeConfig::user_transport`, fallback to the env var
2658 // `ZERODDS_USER_TRANSPORT` (bench binaries), otherwise UDPv4.
2659 // SPDP multicast stays UDPv4 — the DDSI-RTPS spec mandates
2660 // 239.255.0.1 for cross-vendor discovery; v6-only hosts
2661 // cannot discover cross-vendor (its own sprint).
2662 let (user_uc, tcp_accept_handle): (Arc<dyn Transport + Send + Sync>, _) =
2663 if !config.user_transports.is_empty() {
2664 // Multi-transport: build each kind and layer them. Preference =
2665 // config order (first match by destination locator kind wins).
2666 let mut legs: alloc::vec::Vec<Arc<dyn Transport + Send + Sync>> =
2667 alloc::vec::Vec::new();
2668 let mut tcp_handle = None;
2669 for kind in &config.user_transports {
2670 let (leg, tcp) = select_user_transport(*kind, guid_prefix, domain_id, pinned)?;
2671 legs.push(leg);
2672 if tcp.is_some() {
2673 tcp_handle = tcp;
2674 }
2675 }
2676 let layered = Arc::new(crate::layered_transport::LayeredUserTransport::new(legs));
2677 (layered, tcp_handle)
2678 } else {
2679 let user_transport_kind = config
2680 .user_transport
2681 .or_else(parse_user_transport_env)
2682 .unwrap_or(UserTransportKind::UdpV4);
2683 select_user_transport(user_transport_kind, guid_prefix, domain_id, pinned)?
2684 };
2685
2686 // Separate sender socket for the SPDP announce. Ephemeral port; with
2687 // interface pinning it binds to the pinned IP (egress source), otherwise
2688 // `0.0.0.0` (the kernel picks the outgoing interface per route).
2689 let spdp_mc_tx =
2690 UdpTransport::bind_v4(pinned, 0).map_err(|_| DdsError::TransportError {
2691 label: "spdp mc-tx bind",
2692 })?;
2693
2694 let stop = Arc::new(AtomicBool::new(false));
2695
2696 // Materialize beacon locators for cross-host interop:
2697 // with a `0.0.0.0` bind address (UNSPECIFIED) the peer would
2698 // otherwise learn a non-routable address. We resolve UNSPECIFIED
2699 // via a UDP connect probe to a non-routable IP
2700 // (no traffic, just the routing table) and announce the
2701 // resulting local interface address — cross-host-capable
2702 // without an external crate dependency.
2703 let user_locator = announce_locator(&*user_uc, config.multicast_interface);
2704 let spdp_uc_locator = announce_locator(&spdp_uc, config.multicast_interface);
2705 let participant_data = ParticipantBuiltinTopicData {
2706 guid: Guid::new(guid_prefix, EntityId::PARTICIPANT),
2707 protocol_version: ProtocolVersion::V2_5,
2708 vendor_id: VendorId::ZERODDS,
2709 default_unicast_locator: Some(user_locator),
2710 default_multicast_locator: None,
2711 metatraffic_unicast_locator: Some(spdp_uc_locator),
2712 metatraffic_multicast_locator: Some(Locator {
2713 kind: LocatorKind::UdpV4,
2714 port: u32::from(spdp_port),
2715 address: {
2716 let mut a = [0u8; 16];
2717 a[12..].copy_from_slice(&config.spdp_multicast_group.octets());
2718 a
2719 },
2720 }),
2721 domain_id: Some(domain_id as u32),
2722 // We announce the endpoints we actually
2723 // implement: SPDP (participant ann/det) + SEDP
2724 // (publications/subscriptions ann+det) + WLP (10/11) +
2725 // TypeLookup service (12/13). Cyclone/Fast-DDS filter
2726 // their proxy setup by these flags — without them
2727 // we get no SEDP/WLP peers. SEDP topic
2728 // endpoints (bits 28/29) are optional per RTPS 2.5 §8.5.4.4
2729 // and covered in ZeroDDS via synthetic DCPSTopic
2730 // derivation from pub/sub — we do not announce them,
2731 // otherwise we promise peers a non-existent
2732 // endpoint pairing. When the caller sets
2733 // `announce_secure_endpoints = true` (security
2734 // factory path), we additionally mix in the 12 secure
2735 // discovery bits (16..27, DDS-Security 1.2 §7.4.7.1).
2736 builtin_endpoint_set: {
2737 let mut mask = endpoint_flag::ALL_STANDARD;
2738 if config.announce_secure_endpoints {
2739 mask |= endpoint_flag::ALL_SECURE;
2740 }
2741 mask
2742 },
2743 // Spec default lease = 100 s; configurable via
2744 // `RuntimeConfig::participant_lease_duration`.
2745 lease_duration: qos_duration_from_std(config.participant_lease_duration),
2746 // UserData on the participant — filled from
2747 // `DomainParticipantQos::user_data` via RuntimeConfig.
2748 user_data: config.user_data.clone(),
2749 // PROPERTY_LIST: security fills this with security caps
2750 // once a PolicyEngine is configured. Default-empty
2751 // stays backward-compatible with legacy peers.
2752 properties: Default::default(),
2753 // IdentityToken/PermissionsToken are filled by the security
2754 // layer once authentication + access control are
2755 // initialized. Default `None` = legacy announce.
2756 identity_token: None,
2757 permissions_token: None,
2758 identity_status_token: None,
2759 sig_algo_info: None,
2760 kx_algo_info: None,
2761 sym_cipher_algo_info: None,
2762 // Filled by the security layer (enable_security_builtins*) —
2763 // without PID_PARTICIPANT_SECURITY_INFO foreign vendors classify
2764 // us as non-secure. Default None = legacy/plain.
2765 participant_security_info: None,
2766 };
2767 let beacon = SpdpBeacon::new(participant_data.clone());
2768 let sedp = SedpStack::new(guid_prefix, VendorId::ZERODDS);
2769 // In-process discovery fastpath: remember the multicast group before
2770 // `config` is moved into the struct literal.
2771 let inproc_group = config.spdp_multicast_group;
2772
2773 #[cfg(feature = "security")]
2774 let outbound_pool = if config.interface_bindings.is_empty() {
2775 None
2776 } else {
2777 Some(Arc::new(OutboundSocketPool::bind_all(
2778 &config.interface_bindings,
2779 )?))
2780 };
2781
2782 // WLP endpoint (RTPS 2.5 §8.4.13). The tick period is explicit
2783 // `wlp_period`, or `lease/3` when `wlp_period == ZERO`
2784 // (spec recommendation: three misses before the reader marks the
2785 // writer as not-alive).
2786 let wlp_tick_period = if config.wlp_period.is_zero() {
2787 config.participant_lease_duration / 3
2788 } else {
2789 config.wlp_period
2790 };
2791 let wlp = crate::wlp::WlpEndpoint::new(guid_prefix, VendorId::ZERODDS, wlp_tick_period);
2792
2793 let rt = Arc::new(Self {
2794 guid_prefix,
2795 domain_id,
2796 spdp_multicast_rx: Arc::new(spdp_mc),
2797 spdp_unicast: Arc::new(spdp_uc),
2798 user_unicast: user_uc,
2799 user_announce_locator: user_locator,
2800 spdp_mc_tx: Arc::new(spdp_mc_tx),
2801 spdp_beacon: Mutex::new(beacon),
2802 participant_data,
2803 announced_pubs: Mutex::new(Vec::new()),
2804 announced_subs: Mutex::new(Vec::new()),
2805 spdp_reader: SpdpReader::new(),
2806 discovered: Arc::new(Mutex::new(DiscoveredParticipantsCache::new())),
2807 sedp: Arc::new(Mutex::new(sedp)),
2808 type_lookup_endpoints: TypeLookupEndpoints::new(guid_prefix),
2809 type_lookup_server: Arc::new(Mutex::new(TypeLookupServer::new())),
2810 type_lookup_client: Arc::new(Mutex::new(TypeLookupClient::new())),
2811 tl_reply_sn: core::sync::atomic::AtomicU64::new(0),
2812 security_builtin: Mutex::new(None),
2813 start_instant: Instant::now(),
2814 user_writers: Arc::new(RwLock::new(BTreeMap::new())),
2815 shm_locators: Arc::new(RwLock::new(BTreeMap::new())),
2816 same_host: Arc::new(crate::same_host::SameHostTracker::new()),
2817 user_readers: Arc::new(RwLock::new(BTreeMap::new())),
2818 #[cfg(feature = "security")]
2819 endpoint_tokens_sent: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
2820 #[cfg(feature = "security")]
2821 sedp_reannounced: Arc::new(RwLock::new(alloc::collections::BTreeSet::new())),
2822 #[cfg(feature = "security")]
2823 endpoint_crypto: Arc::new(RwLock::new(alloc::collections::BTreeMap::new())),
2824 intra_runtime_routes: Arc::new(RwLock::new(BTreeMap::new())),
2825 entity_counter: AtomicU32::new(1),
2826 config,
2827 stop: stop.clone(),
2828 tick_seq: AtomicU64::new(0),
2829 spdp_announce_seq: AtomicU64::new(0),
2830 inconsistent_topic_seq: AtomicU64::new(0),
2831 tick_wake: Mutex::new(None),
2832 tick_wake_pending: AtomicBool::new(false),
2833 handles: Mutex::new(Vec::new()),
2834 match_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
2835 ack_event: Arc::new((Mutex::new(()), std::sync::Condvar::new())),
2836 #[cfg(feature = "security")]
2837 outbound_pool,
2838 wlp: Arc::new(Mutex::new(wlp)),
2839 builtin_sinks: Mutex::new(None),
2840 ignore_filter: Mutex::new(None),
2841 });
2842
2843 // In-process discovery fastpath: register the runtime in the process
2844 // registry so same-process+domain peers find each other
2845 // deterministically (see `crate::inproc`). Right
2846 // after, `pull-on-creation`: pull all already-announced endpoints
2847 // of existing peers into our SEDP cache — otherwise
2848 // we see peers that announced endpoints BEFORE us
2849 // only via the (lossy) UDP SEDP path.
2850 crate::inproc::register(&rt, domain_id, inproc_group);
2851 rt.inproc_pull_from_peers();
2852
2853 // Per-socket recv threads + one tick thread (Sprint D.5b).
2854 //
2855 // Previously the entire stack ran in a single event loop
2856 // that went through three blocking `recv()`s with a `tick_period`
2857 // timeout (50 ms) sequentially per iteration. On a
2858 // roundtrip each stage waited up to 50 ms for timeouts of the
2859 // front sockets before its own datagram got its turn —
2860 // yielded 5-14 ms p50.
2861 //
2862 // Refit: every relevant recv path has its own thread
2863 // that sits directly blocking on its socket and dispatches
2864 // immediately when data arrives. The tick thread does the
2865 // periodic outbound work (HEARTBEAT/resend/ACKNACK/
2866 // SPDP announce/deadline/lifespan/liveliness) and sleeps
2867 // `tick_period` between iterations.
2868 //
2869 // Lock order (deadlock avoidance): the tick thread and
2870 // recv threads contend for `rt.sedp.lock()` / `rt.wlp.lock()`.
2871 // Convention: keep lock-hold times short (handle_datagram /
2872 // tick are both fast), do not take a sub-lock under the `sedp`
2873 // or `wlp` lock.
2874 let mut handles_init: Vec<JoinHandle<()>> = Vec::with_capacity(4);
2875
2876 let rt_recv_spdp_mc = Arc::clone(&rt);
2877 let stop_recv_spdp_mc = stop.clone();
2878 handles_init.push(
2879 thread::Builder::new()
2880 .name(String::from("zdds-recv-spdp-mc"))
2881 .spawn(move || recv_spdp_multicast_loop(rt_recv_spdp_mc, stop_recv_spdp_mc))
2882 .map_err(|_| DdsError::PreconditionNotMet {
2883 reason: "spawn zdds-recv-spdp-mc thread",
2884 })?,
2885 );
2886
2887 let rt_recv_meta = Arc::clone(&rt);
2888 let stop_recv_meta = stop.clone();
2889 handles_init.push(
2890 thread::Builder::new()
2891 .name(String::from("zdds-recv-meta"))
2892 .spawn(move || recv_metatraffic_loop(rt_recv_meta, stop_recv_meta))
2893 .map_err(|_| DdsError::PreconditionNotMet {
2894 reason: "spawn zdds-recv-meta thread",
2895 })?,
2896 );
2897
2898 let rt_recv_user = Arc::clone(&rt);
2899 let stop_recv_user = stop.clone();
2900 let primary_socket = Arc::clone(&rt.user_unicast);
2901 handles_init.push(
2902 thread::Builder::new()
2903 .name(String::from("zdds-recv-user"))
2904 .spawn(move || recv_user_data_loop(rt_recv_user, primary_socket, stop_recv_user))
2905 .map_err(|_| DdsError::PreconditionNotMet {
2906 reason: "spawn zdds-recv-user thread",
2907 })?,
2908 );
2909
2910 // TCPv4 variant: a separate accept worker (TcpTransport has
2911 // no implicit accept thread in the constructor — accept_one()
2912 // must be called explicitly).
2913 if let Some(tcp_arc) = tcp_accept_handle {
2914 let stop_accept = stop.clone();
2915 handles_init.push(
2916 thread::Builder::new()
2917 .name(String::from("zdds-tcp-accept"))
2918 .spawn(move || {
2919 while !stop_accept.load(Ordering::Relaxed) {
2920 // accept_one() blocks until connection +
2921 // handshake; on EOF it returns Ok(()) and
2922 // we accept the next peer.
2923 let _ = tcp_arc.accept_one();
2924 }
2925 })
2926 .map_err(|_| DdsError::PreconditionNotMet {
2927 reason: "spawn zdds-tcp-accept thread",
2928 })?,
2929 );
2930 }
2931
2932 // Opt-3 (Spec `zerodds-zero-copy-1.0` §9): additional
2933 // SO_REUSEPORT recv workers. Each binds to the same
2934 // user_unicast port; the kernel distributes incoming datagrams via
2935 // flow hash. On a bind error (e.g. a platform without
2936 // SO_REUSEPORT support) the worker is skipped and the
2937 // runtime continues with the available workers.
2938 if rt.config.extra_recv_threads > 0 {
2939 let user_port = u16::try_from(rt.user_unicast.local_locator().port).unwrap_or(0);
2940 // With an active security config, share the first interface bind address;
2941 // otherwise INADDR_ANY (the kernel chooses).
2942 #[cfg(feature = "security")]
2943 let bind_addr = rt
2944 .config
2945 .interface_bindings
2946 .first()
2947 .map(|spec| spec.bind_addr)
2948 .unwrap_or(Ipv4Addr::UNSPECIFIED);
2949 #[cfg(not(feature = "security"))]
2950 let bind_addr = Ipv4Addr::UNSPECIFIED;
2951 for i in 0..rt.config.extra_recv_threads {
2952 let extra_socket =
2953 match UdpTransport::bind_v4_reuse(bind_addr, user_port) {
2954 Ok(t) => Arc::new(t.with_timeout(Some(Duration::from_secs(1))).map_err(
2955 |_| DdsError::TransportError {
2956 label: "extra-recv set_timeout failed",
2957 },
2958 )?),
2959 Err(_) => break, // SO_REUSEPORT not available — skip.
2960 };
2961 let rt_extra = Arc::clone(&rt);
2962 let stop_extra = stop.clone();
2963 let name = format!("zdds-recv-user-{}", i + 1);
2964 handles_init.push(
2965 thread::Builder::new()
2966 .name(name)
2967 .spawn(move || recv_user_data_loop(rt_extra, extra_socket, stop_extra))
2968 .map_err(|_| DdsError::PreconditionNotMet {
2969 reason: "spawn zdds-recv-user-N thread",
2970 })?,
2971 );
2972 }
2973 }
2974
2975 // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6): per-owner
2976 // SHM recv loop. Polls all bound consumer entries of the
2977 // SameHostTracker round-robin and dispatches incoming
2978 // frames analogous to the UDP path. Only compiled when
2979 // the `same-host-shm` feature is on.
2980 #[cfg(feature = "same-host-shm")]
2981 {
2982 let rt_recv_shm = Arc::clone(&rt);
2983 let stop_recv_shm = stop.clone();
2984 handles_init.push(
2985 thread::Builder::new()
2986 .name(String::from("zdds-recv-shm"))
2987 .spawn(move || recv_user_shm_loop(rt_recv_shm, stop_recv_shm))
2988 .map_err(|_| DdsError::PreconditionNotMet {
2989 reason: "spawn zdds-recv-shm thread",
2990 })?,
2991 );
2992 }
2993
2994 // zerodds-async-1.0 §4: with `external_tick`, the tick loop is driven
2995 // by an external executor (tokio via `spawn_in_tokio`) rather than a
2996 // dedicated thread — so we skip the spawn here. `stop` is dropped; the
2997 // driver observes shutdown via `rt.stop` (set in `Drop`/`stop()`).
2998 if rt.config.external_tick {
2999 drop(stop);
3000 } else if rt.config.scheduler_tick {
3001 // D.5e Phase 3 — event-driven scheduler tick. Create the scheduler
3002 // up front, publish its wake handle so recv loops + the write path
3003 // can `raise_tick_wake`, then drive the (unchanged) tick from the
3004 // deadline-heap worker.
3005 let (scheduler, handle) =
3006 crate::scheduler::Scheduler::<TickEvent>::new(SCHEDULER_IDLE_FLOOR);
3007 if let Ok(mut g) = rt.tick_wake.lock() {
3008 *g = Some(handle.clone());
3009 }
3010 let rt_tick = Arc::clone(&rt);
3011 let stop_tick = stop;
3012 handles_init.push(
3013 thread::Builder::new()
3014 .name(String::from("zdds-tick-sched"))
3015 .spawn(move || scheduler_tick_loop(rt_tick, stop_tick, scheduler, handle))
3016 .map_err(|_| DdsError::PreconditionNotMet {
3017 reason: "spawn zdds-tick-sched thread",
3018 })?,
3019 );
3020 } else {
3021 let rt_tick = Arc::clone(&rt);
3022 let stop_tick = stop;
3023 handles_init.push(
3024 thread::Builder::new()
3025 .name(String::from("zdds-tick"))
3026 .spawn(move || tick_loop(rt_tick, stop_tick))
3027 .map_err(|_| DdsError::PreconditionNotMet {
3028 reason: "spawn zdds-tick thread",
3029 })?,
3030 );
3031 }
3032
3033 let mut guard = rt
3034 .handles
3035 .lock()
3036 .map_err(|_| DdsError::PreconditionNotMet {
3037 reason: "runtime handles mutex poisoned",
3038 })?;
3039 *guard = handles_init;
3040 drop(guard);
3041
3042 Ok(rt)
3043 }
3044
3045 /// Local unicast locator for user data (announced in SPDP).
3046 #[must_use]
3047 pub fn user_locator(&self) -> zerodds_rtps::wire_types::Locator {
3048 self.user_unicast.local_locator()
3049 }
3050
3051 /// Local unicast locator for SPDP metatraffic.
3052 #[must_use]
3053 pub fn spdp_unicast_locator(&self) -> zerodds_rtps::wire_types::Locator {
3054 self.spdp_unicast.local_locator()
3055 }
3056
3057 /// Returns the `BuiltinEndpointSet` bitmask that the runtime
3058 /// currently announces in the SPDP beacon. Used for tests + diagnostics;
3059 /// production consumers should decode the SPDP beacon
3060 /// themselves.
3061 #[must_use]
3062 pub fn announced_builtin_endpoint_set(&self) -> u32 {
3063 self.spdp_beacon
3064 .lock()
3065 .map(|b| b.data.builtin_endpoint_set)
3066 .unwrap_or(0)
3067 }
3068
3069 /// Registers a `TypeObject` in the local TypeLookup server
3070 /// registry. Other participants can then query this type via
3071 /// a `getTypes` request (XTypes 1.3 §7.6.3.3.4).
3072 ///
3073 /// Returns the `EquivalenceHash` of the registered type
3074 /// (the caller can embed it e.g. in `PublicationBuiltinTopicData` as a
3075 /// PID_TYPE_INFORMATION hint).
3076 ///
3077 /// # Errors
3078 /// `DdsError::PreconditionNotMet` on lock poisoning or a hash
3079 /// computation error.
3080 pub fn register_type_object(
3081 &self,
3082 obj: zerodds_types::type_object::TypeObject,
3083 ) -> Result<zerodds_types::EquivalenceHash> {
3084 let hash = zerodds_types::compute_hash(&obj).map_err(|_| DdsError::PreconditionNotMet {
3085 reason: "type hash computation failed",
3086 })?;
3087 let mut server =
3088 self.type_lookup_server
3089 .lock()
3090 .map_err(|_| DdsError::PreconditionNotMet {
3091 reason: "type_lookup_server mutex poisoned",
3092 })?;
3093 match obj {
3094 zerodds_types::type_object::TypeObject::Minimal(m) => {
3095 server.registry.insert_minimal(hash, m);
3096 }
3097 zerodds_types::type_object::TypeObject::Complete(c) => {
3098 server.registry.insert_complete(hash, c);
3099 }
3100 _ => {
3101 return Err(DdsError::PreconditionNotMet {
3102 reason: "unknown TypeObject variant",
3103 });
3104 }
3105 }
3106 Ok(hash)
3107 }
3108
3109 /// Sends a `getTypes` request to a discovered peer and
3110 /// returns a `RequestId` with which the caller can correlate the
3111 /// asynchronous reply later (XTypes 1.3
3112 /// §7.6.3.3.4 + `TypeLookupClient::handle_reply`).
3113 ///
3114 /// `peer` must be in `discovered_participants()` — otherwise
3115 /// `None` is returned (no known peer locator). On a
3116 /// successful send the request sample-identity sequence
3117 /// is returned as the `RequestId`; an incoming reply is correlated on
3118 /// this sequence ID.
3119 ///
3120 /// # Errors
3121 /// `DdsError::PreconditionNotMet` on encode errors or lock
3122 /// poisoning.
3123 pub fn send_type_lookup_request(
3124 &self,
3125 peer: zerodds_rtps::wire_types::GuidPrefix,
3126 type_hashes: &[zerodds_types::EquivalenceHash],
3127 ) -> Result<Option<zerodds_discovery::type_lookup::RequestId>> {
3128 use alloc::sync::Arc as AllocArc;
3129 use zerodds_discovery::type_lookup::request_types_payload;
3130 use zerodds_rtps::datagram::encode_data_datagram;
3131 use zerodds_rtps::header::RtpsHeader;
3132 use zerodds_rtps::submessages::DataSubmessage;
3133 use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber};
3134
3135 // Find peer's user-unicast locator (default-unicast first;
3136 // fallback metatraffic-unicast). TypeLookup datagrams go via
3137 // the user-unicast path — the peer DCPS runtime has a
3138 // shared receive loop there for SEDP/user data/TypeLookup.
3139 let target = {
3140 let discovered = self
3141 .discovered
3142 .lock()
3143 .map_err(|_| DdsError::PreconditionNotMet {
3144 reason: "discovered mutex poisoned",
3145 })?;
3146 let Some(dp) = discovered.get(&peer) else {
3147 return Ok(None);
3148 };
3149 dp.data
3150 .default_unicast_locator
3151 .or(dp.data.metatraffic_unicast_locator)
3152 };
3153 let Some(target) = target else {
3154 return Ok(None);
3155 };
3156
3157 // Allocate RequestId (client-side incrementing sequence). Reply
3158 // correlation runs via the `handle_reply` callback. We
3159 // register a callback that feeds the returned
3160 // TypeObjects into the local `TypeLookupServer.registry`
3161 // (XTypes 1.3 §7.6.3.3.4): hash-by-hash, separately
3162 // for Minimal and Complete variants. This way a hash that
3163 // was resolved once is recognized for future `has_type_for_hash`
3164 // checks (= no re-requests).
3165 let mut client =
3166 self.type_lookup_client
3167 .lock()
3168 .map_err(|_| DdsError::PreconditionNotMet {
3169 reason: "type_lookup_client mutex poisoned",
3170 })?;
3171 let type_ids: alloc::vec::Vec<zerodds_types::TypeIdentifier> = type_hashes
3172 .iter()
3173 .map(|h| zerodds_types::TypeIdentifier::EquivalenceHashMinimal(*h))
3174 .collect();
3175 let server_for_cb = Arc::clone(&self.type_lookup_server);
3176 let cb = Box::new(
3177 move |reply: zerodds_discovery::type_lookup::TypeLookupReply| {
3178 let zerodds_discovery::type_lookup::TypeLookupReply::Types(types_reply) = reply
3179 else {
3180 return;
3181 };
3182 let Ok(mut server) = server_for_cb.lock() else {
3183 return;
3184 };
3185 for t in &types_reply.types {
3186 match t {
3187 zerodds_types::type_lookup::ReplyTypeObject::Minimal(m) => {
3188 let to = zerodds_types::type_object::TypeObject::Minimal(m.clone());
3189 if let Ok(h) = zerodds_types::compute_hash(&to) {
3190 server.registry.insert_minimal(h, m.clone());
3191 }
3192 }
3193 zerodds_types::type_lookup::ReplyTypeObject::Complete(c) => {
3194 let to = zerodds_types::type_object::TypeObject::Complete(c.clone());
3195 if let Ok(h) = zerodds_types::compute_hash(&to) {
3196 server.registry.insert_complete(h, c.clone());
3197 }
3198 }
3199 }
3200 }
3201 },
3202 );
3203 let request_id = client.request_types(type_ids.clone(), cb);
3204 drop(client);
3205
3206 // Encode the wire request payload (PL_CDR_LE-Encapsulation).
3207 let body = request_types_payload(&type_ids).map_err(|_| DdsError::PreconditionNotMet {
3208 reason: "type_lookup request payload encode failed",
3209 })?;
3210 let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
3211 payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
3212 payload.extend_from_slice(&body);
3213
3214 // Use the RequestId as the writer_sn so the peer-side reply can
3215 // echo it for correlation (XTypes §7.6.3.3.3 Sample-Identity).
3216 let id_u64 = request_id.0;
3217 let sn =
3218 SequenceNumber::from_high_low((id_u64 >> 32) as i32, (id_u64 & 0xFFFF_FFFF) as u32);
3219 let header = RtpsHeader {
3220 protocol_version: ProtocolVersion::CURRENT,
3221 vendor_id: VendorId::ZERODDS,
3222 guid_prefix: self.guid_prefix,
3223 };
3224 let data = DataSubmessage {
3225 extra_flags: 0,
3226 reader_id: EntityId::TL_SVC_REQ_READER,
3227 writer_id: EntityId::TL_SVC_REQ_WRITER,
3228 writer_sn: sn,
3229 inline_qos: None,
3230 key_flag: false,
3231 non_standard_flag: false,
3232 serialized_payload: AllocArc::from(payload.into_boxed_slice()),
3233 };
3234 let datagram =
3235 encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
3236 reason: "type_lookup request datagram encode failed",
3237 })?;
3238
3239 if is_routable_user_locator(&target) {
3240 let _ = self.user_unicast.send(&target, &datagram);
3241 }
3242 Ok(Some(request_id))
3243 }
3244
3245 /// Activates the security builtin endpoint stack
3246 /// (`DCPSParticipantStatelessMessage` + `DCPSParticipantVolatile-
3247 /// MessageSecure`). Typically called by the factory
3248 /// once a security plugin is registered on the participant.
3249 /// Idempotent: a second call has no effect. Returns the (possibly
3250 /// freshly created) stack handle.
3251 pub fn enable_security_builtins(
3252 &self,
3253 vendor_id: VendorId,
3254 ) -> Arc<Mutex<SecurityBuiltinStack>> {
3255 self.install_security_stack(SecurityBuiltinStack::new(self.guid_prefix, vendor_id))
3256 }
3257
3258 /// Like [`enable_security_builtins`](Self::enable_security_builtins),
3259 /// but with an active auth-handshake driver (FU2 Gap 4). The stack
3260 /// is built via [`SecurityBuiltinStack::with_auth`]: the shared
3261 /// `auth` plugin (= the same instance that hangs on the crypto gate as
3262 /// the `SharedSecretProvider`) drives the PKI handshake as soon as
3263 /// a peer with stateless bits + identity token is discovered.
3264 ///
3265 /// `local_identity` comes from `validate_local_identity`; the local
3266 /// 16-byte participant GUID is derived from the `guid_prefix`.
3267 ///
3268 /// Idempotent (first-wins): if a stack is already active — even one
3269 /// built without auth — that one is returned and the freshly
3270 /// built one discarded.
3271 #[cfg(feature = "security")]
3272 pub fn enable_security_builtins_with_auth(
3273 self: &Arc<Self>,
3274 vendor_id: VendorId,
3275 auth: Arc<Mutex<dyn zerodds_security::authentication::AuthenticationPlugin>>,
3276 local_identity: zerodds_security::authentication::IdentityHandle,
3277 ) -> Arc<Mutex<SecurityBuiltinStack>> {
3278 let local_guid = Guid::new(self.guid_prefix, EntityId::PARTICIPANT).to_bytes();
3279 // Announce the local IdentityToken in the SPDP beacon (PID_IDENTITY_TOKEN,
3280 // FU2 Gap 7c) + set the stateless/volatile-secure bits, so peers
3281 // initiate the auth handshake. Before moving `auth` into the stack.
3282 if let Ok(mut plugin) = auth.lock() {
3283 if let Ok(token) = plugin.get_identity_token(local_identity) {
3284 // PID_PERMISSIONS_TOKEN (§7.4.1.5, S4 point 1): secure
3285 // vendors (cyclone/FastDDS) start validate_remote_identity
3286 // only when SPDP carries identity_token AND permissions_token;
3287 // otherwise we stay non-secure and all endpoints are "not
3288 // allowed". Empty if no permissions are configured.
3289 let perm_token = plugin.get_permissions_token();
3290 let pdata = if let Ok(mut beacon) = self.spdp_beacon.lock() {
3291 if !token.is_empty() {
3292 beacon.data.identity_token = Some(token);
3293 }
3294 if !perm_token.is_empty() {
3295 beacon.data.permissions_token = Some(perm_token);
3296 }
3297 // Full secure builtin endpoint set (§7.4.7.1): stateless +
3298 // VolatileSecure (22-25) PLUS secure SEDP (16-19),
3299 // secure ParticipantMessage (20-21) and DCPSParticipantsSecure
3300 // (26-27). cyclone starts validate_remote_identity + creates the
3301 // secure builtin proxies ONLY when the remote announces the full
3302 // secure set (cyclone-trace-verified) — only
3303 // 22-25 → "Non secure remote ... not allowed", no handshake.
3304 beacon.data.builtin_endpoint_set |= endpoint_flag::PUBLICATIONS_SECURE_WRITER
3305 | endpoint_flag::PUBLICATIONS_SECURE_READER
3306 | endpoint_flag::SUBSCRIPTIONS_SECURE_WRITER
3307 | endpoint_flag::SUBSCRIPTIONS_SECURE_READER
3308 | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_WRITER
3309 | endpoint_flag::PARTICIPANT_MESSAGE_SECURE_READER
3310 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
3311 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
3312 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
3313 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
3314 | endpoint_flag::PARTICIPANT_SECURE_WRITER
3315 | endpoint_flag::PARTICIPANT_SECURE_READER;
3316 // PID_PARTICIPANT_SECURITY_INFO (§7.4.1.6): marks us as a
3317 // secure participant — mandatory, otherwise cyclone/
3318 // FastDDS treat us as non-secure and reject all endpoints.
3319 // IS_VALID on both masks; derive the participant-level
3320 // ParticipantSecurityAttributes (§9.4.2.4) from the governance:
3321 // is_{rtps,discovery,liveliness}_protected in the
3322 // attr mask, is_*_encrypted in the plugin mask. cyclone
3323 // matches the announced bits against its own governance —
3324 // a null mask with e.g. discovery=ENCRYPT is a policy
3325 // mismatch and cyclone then establishes NO secured
3326 // participant crypto handshake (bug source protected discovery).
3327 use zerodds_rtps::participant_security_info::{
3328 ParticipantSecurityInfo, attrs, plugin_attrs,
3329 };
3330 let (mut a, mut p) = (attrs::IS_VALID, plugin_attrs::IS_VALID);
3331 if let Some(gate) = self.config.security.as_ref() {
3332 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
3333 let disc = gate.discovery_protection().unwrap_or(ProtectionLevel::None);
3334 let live = gate
3335 .liveliness_protection()
3336 .unwrap_or(ProtectionLevel::None);
3337 if rtps != ProtectionLevel::None {
3338 a |= attrs::IS_RTPS_PROTECTED;
3339 }
3340 if disc != ProtectionLevel::None {
3341 a |= attrs::IS_DISCOVERY_PROTECTED;
3342 }
3343 if live != ProtectionLevel::None {
3344 a |= attrs::IS_LIVELINESS_PROTECTED;
3345 }
3346 if rtps == ProtectionLevel::Encrypt {
3347 p |= plugin_attrs::IS_RTPS_ENCRYPTED;
3348 }
3349 if disc == ProtectionLevel::Encrypt {
3350 p |= plugin_attrs::IS_DISCOVERY_ENCRYPTED;
3351 }
3352 if live == ProtectionLevel::Encrypt {
3353 p |= plugin_attrs::IS_LIVELINESS_ENCRYPTED;
3354 }
3355 }
3356 beacon.data.participant_security_info = Some(ParticipantSecurityInfo {
3357 participant_security_attributes: a,
3358 plugin_participant_security_attributes: p,
3359 });
3360 // c.pdata (§9.3.2.5.2, S4 root 6+7): our own
3361 // ParticipantBuiltinTopicData as PL_CDR_**BE** — the replier
3362 // (cyclone) deserializes c.pdata strictly as a big-endian
3363 // ParameterList and binds the participant_guid to the
3364 // authenticated identity. LE → "payload too long".
3365 Some(beacon.data.to_pl_cdr_be())
3366 } else {
3367 None
3368 };
3369 if let Some(pd) = pdata {
3370 plugin.set_local_participant_data(pd);
3371 }
3372 }
3373 }
3374 let stack = self.install_security_stack(SecurityBuiltinStack::with_auth(
3375 self.guid_prefix,
3376 vendor_id,
3377 auth,
3378 local_identity,
3379 local_guid,
3380 ));
3381 // FU2 S3: kick off in-process participant discovery + handshake trigger
3382 // deterministically — decouples the secured discovery from the
3383 // flaky multicast path (codepit LXC). Bidirectional, idempotent.
3384 self.inproc_announce_participant();
3385 // FU2 S3: immediate token-carrying SPDP re-announce (event-driven).
3386 self.announce_spdp_now();
3387 stack
3388 }
3389
3390 /// Installs a freshly built `SecurityBuiltinStack` into the
3391 /// runtime slot (idempotent) and catches up on peers already
3392 /// discovered via SPDP. Shared core of
3393 /// [`enable_security_builtins`](Self::enable_security_builtins) and
3394 /// [`enable_security_builtins_with_auth`](Self::enable_security_builtins_with_auth).
3395 fn install_security_stack(
3396 &self,
3397 fresh: SecurityBuiltinStack,
3398 ) -> Arc<Mutex<SecurityBuiltinStack>> {
3399 // Lock poisoning is a bug indicator here (an earlier panic in the
3400 // hot path). In that case we return a fresh, isolated
3401 // stack — the caller gets at least a
3402 // functional slot, but the hot path writes its mutations
3403 // into the unlocked original. In production code this does not happen;
3404 // in tests (where poisoning can occur) this is a
3405 // best-effort recovery.
3406 let mut slot = match self.security_builtin.lock() {
3407 Ok(g) => g,
3408 Err(_) => {
3409 return Arc::new(Mutex::new(fresh));
3410 }
3411 };
3412 if let Some(existing) = slot.as_ref() {
3413 return Arc::clone(existing);
3414 }
3415 let stack = Arc::new(Mutex::new(fresh));
3416 // Catch up on already-discovered peers (discovery may have already
3417 // seen SPDP beacons before the plugin was activated).
3418 if let Ok(cache) = self.discovered.lock() {
3419 if let Ok(mut s) = stack.lock() {
3420 for peer in cache.iter() {
3421 s.handle_remote_endpoints(peer);
3422 }
3423 }
3424 }
3425 *slot = Some(Arc::clone(&stack));
3426 // Protected discovery (DDS-Security §8.4.2.4): if the governance demands
3427 // `discovery_protection_kind != NONE`, the SedpStack routes secured
3428 // endpoints via the secure SEDP (DCPSPublicationsSecure/Subscriptions
3429 // Secure) instead of plaintext — the runtime send path protects their DATA/
3430 // HEARTBEAT/GAP with the participant data key. Set before the first
3431 // announce_* (endpoint creation follows the security activation).
3432 #[cfg(feature = "security")]
3433 if let Some(gate) = self.config.security.as_ref() {
3434 let protected = gate
3435 .discovery_protection()
3436 .map(|l| l != ProtectionLevel::None)
3437 .unwrap_or(false);
3438 if protected {
3439 if let Ok(mut sedp) = self.sedp.lock() {
3440 sedp.set_discovery_protected(true);
3441 }
3442 }
3443 }
3444 stack
3445 }
3446
3447 /// Snapshot handle on the security builtin stack. `None` if
3448 /// [`enable_security_builtins`](Self::enable_security_builtins)
3449 /// has not been called yet.
3450 #[must_use]
3451 pub fn security_builtin_snapshot(&self) -> Option<Arc<Mutex<SecurityBuiltinStack>>> {
3452 self.security_builtin.lock().ok()?.as_ref().map(Arc::clone)
3453 }
3454
3455 /// `assert_liveliness()` on the `DomainParticipant` (DCPS 1.4
3456 /// §2.2.3.11 MANUAL_BY_PARTICIPANT). Sends exactly one WLP heartbeat
3457 /// with `kind = MANUAL_BY_PARTICIPANT` on the next tick;
3458 /// all readers matching this participant refresh their
3459 /// last-seen timestamp. Idempotent — multiple calls within
3460 /// one tick period result in multiple wire sends up to the
3461 /// cap (`MAX_QUEUED_PULSES = 32`).
3462 pub fn assert_liveliness(&self) {
3463 if let Ok(mut wlp) = self.wlp.lock() {
3464 wlp.assert_participant();
3465 }
3466 }
3467
3468 /// `assert_liveliness()` on a `DataWriter` (DCPS 1.4 §2.2.3.11
3469 /// MANUAL_BY_TOPIC). `topic_token` is an opaque token that
3470 /// matching readers can use to associate the pulse with a concrete
3471 /// topic. We use the ZeroDDS vendor kind (Cyclone /
3472 /// Fast-DDS ignore the vendor kind, which is spec-conformant —
3473 /// MSB-set in `kind` requests "ignore unknown" behavior).
3474 pub fn assert_writer_liveliness(&self, topic_token: Vec<u8>) {
3475 if let Ok(mut wlp) = self.wlp.lock() {
3476 wlp.assert_topic(topic_token);
3477 }
3478 }
3479
3480 /// Current WLP last-seen timestamp of a remote peer (relative
3481 /// to runtime start). `None` if the peer has not sent a WLP
3482 /// heartbeat yet.
3483 #[must_use]
3484 pub fn peer_liveliness_last_seen(&self, prefix: &GuidPrefix) -> Option<Duration> {
3485 self.wlp
3486 .lock()
3487 .ok()
3488 .and_then(|w| w.peer_state(prefix).map(|s| s.last_seen))
3489 }
3490
3491 /// Returns the [`zerodds_discovery::PeerCapabilities`] of a remote
3492 /// peer, based on its most recently received SPDP beacon.
3493 /// `None` if the peer has not been discovered via SPDP yet.
3494 #[must_use]
3495 pub fn peer_capabilities(
3496 &self,
3497 prefix: &GuidPrefix,
3498 ) -> Option<zerodds_discovery::PeerCapabilities> {
3499 self.discovered
3500 .lock()
3501 .ok()
3502 .and_then(|d| d.get(prefix).map(|p| p.data.builtin_endpoint_set))
3503 .map(zerodds_discovery::PeerCapabilities::from_bits)
3504 }
3505
3506 /// Snapshot of the currently discovered remote participants.
3507 /// Key = GUID prefix, value = last seen beacon content.
3508 #[must_use]
3509 pub fn discovered_participants(&self) -> Vec<DiscoveredParticipant> {
3510 self.discovered
3511 .lock()
3512 .map(|cache| cache.iter().cloned().collect())
3513 .unwrap_or_default()
3514 }
3515
3516 /// Wires the `BuiltinSinks` of the `DomainParticipant` into the
3517 /// discovery hot path. From this
3518 /// call on, all SPDP/SEDP receive events land as samples in
3519 /// the 4 builtin-topic readers.
3520 ///
3521 /// Called by the `DomainParticipant` constructor exactly once during
3522 /// setup.
3523 pub fn attach_builtin_sinks(&self, sinks: crate::builtin_subscriber::BuiltinSinks) {
3524 if let Ok(mut guard) = self.builtin_sinks.lock() {
3525 *guard = Some(sinks);
3526 }
3527 }
3528
3529 /// Snapshot of the currently wired BuiltinSinks (internal, for the
3530 /// hot path).
3531 pub(crate) fn builtin_sinks_snapshot(&self) -> Option<crate::builtin_subscriber::BuiltinSinks> {
3532 self.builtin_sinks.lock().ok().and_then(|g| g.clone())
3533 }
3534
3535 /// Wires the `IgnoreFilter` of the `DomainParticipant` into the
3536 /// discovery hot path. From
3537 /// this call on, SPDP/SEDP receive events are checked against the
3538 /// filter before being pushed as a builtin sample or used as an
3539 /// SEDP match source.
3540 ///
3541 /// Called by the `DomainParticipant` constructor exactly once during
3542 /// setup.
3543 pub fn attach_ignore_filter(&self, filter: crate::participant::IgnoreFilter) {
3544 if let Ok(mut guard) = self.ignore_filter.lock() {
3545 *guard = Some(filter);
3546 }
3547 }
3548
3549 /// Snapshot of the currently wired IgnoreFilter (internal, for
3550 /// the hot path).
3551 pub(crate) fn ignore_filter_snapshot(&self) -> Option<crate::participant::IgnoreFilter> {
3552 self.ignore_filter.lock().ok().and_then(|g| g.clone())
3553 }
3554
3555 /// Synchronizes the protected-discovery flag of the `SedpStack` with the
3556 /// governance (`discovery_protection_kind`). Idempotent, called before every
3557 /// `announce_*` — so the flag is set correctly regardless of the
3558 /// order in which security activation and endpoint creation ran.
3559 #[cfg(feature = "security")]
3560 fn sync_sedp_discovery_protected(&self, sedp: &mut SedpStack) {
3561 if let Some(gate) = self.config.security.as_ref() {
3562 let protected = gate
3563 .discovery_protection()
3564 .map(|l| l != ProtectionLevel::None)
3565 .unwrap_or(false);
3566 sedp.set_discovery_protected(protected);
3567 }
3568 }
3569
3570 /// Announces a local publication via SEDP. The runtime
3571 /// sends the generated datagrams immediately to all already-
3572 /// discovered remote participants.
3573 ///
3574 /// # Errors
3575 /// `WireError` if encoding fails.
3576 pub fn announce_publication(
3577 &self,
3578 data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3579 ) -> Result<()> {
3580 // In-process discovery fastpath: put it in the stash so a
3581 // peer runtime starting later in the same process can pull us
3582 // via `inproc_snapshot`.
3583 if let Ok(mut v) = self.announced_pubs.lock() {
3584 v.push(data.clone());
3585 }
3586 // ADR-0006: side-map lookup. If the local user writer has a
3587 // same-host backend attached (set_shm_locator was
3588 // called), we inject PID_SHM_LOCATOR into the SEDP
3589 // sample. Otherwise pure 1:1 spec wire.
3590 let shm = self.shm_locator(data.key.entity_id);
3591 let datagrams = {
3592 let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3593 reason: "sedp poisoned",
3594 })?;
3595 // Protected discovery (§8.4.2.4): set robustly before the announce —
3596 // independent of the order of enable_security_builtins vs.
3597 // endpoint creation. `discovery_protection_kind != NONE` routes
3598 // the announce into the secure SEDP writer.
3599 #[cfg(feature = "security")]
3600 self.sync_sedp_discovery_protected(&mut sedp);
3601 let res = if let Some(ref bytes) = shm {
3602 sedp.announce_publication_with_shm_locator(data, bytes)
3603 } else {
3604 sedp.announce_publication(data)
3605 };
3606 res.map_err(|_| DdsError::WireError {
3607 message: alloc::string::String::from("sedp announce_publication"),
3608 })?
3609 };
3610 // Send outside the lock (Rc<Vec<Locator>> is !Send,
3611 // but we are on the same thread as `self` — no
3612 // problem).
3613 for dg in datagrams {
3614 if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3615 for t in dg.targets.iter() {
3616 if is_routable_user_locator(t) {
3617 // §8.3.7: unicast metatraffic (SEDP DATA to the remote
3618 // metatraffic_unicast_locator) MUST go out from the metatraffic
3619 // recv socket `spdp_unicast`, NOT from the ephemeral
3620 // `spdp_mc_tx` — otherwise the peer sees a foreign
3621 // source port and sends its reliable ACKNACK/resends
3622 // to a dead port (cross-vendor SEDP stall). Identical
3623 // to `send_discovery_datagram`.
3624 let _ = self.spdp_unicast.send(t, &secured);
3625 }
3626 }
3627 }
3628 }
3629 // In-process discovery fastpath: serve same-process+domain peers
3630 // synchronously + losslessly with this publication.
3631 self.inproc_announce_publication(data);
3632 Ok(())
3633 }
3634
3635 /// Announces a local subscription via SEDP. Analogous to
3636 /// `announce_publication`.
3637 ///
3638 /// # Errors
3639 /// `WireError` if encoding fails.
3640 pub fn announce_subscription(
3641 &self,
3642 data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3643 ) -> Result<()> {
3644 if let Ok(mut v) = self.announced_subs.lock() {
3645 v.push(data.clone());
3646 }
3647 let datagrams = {
3648 let mut sedp = self.sedp.lock().map_err(|_| DdsError::PreconditionNotMet {
3649 reason: "sedp poisoned",
3650 })?;
3651 #[cfg(feature = "security")]
3652 self.sync_sedp_discovery_protected(&mut sedp);
3653 sedp.announce_subscription(data)
3654 .map_err(|_| DdsError::WireError {
3655 message: alloc::string::String::from("sedp announce_subscription"),
3656 })?
3657 };
3658 for dg in datagrams {
3659 if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3660 for t in dg.targets.iter() {
3661 if is_routable_user_locator(t) {
3662 // Source port: metatraffic recv socket, not spdp_mc_tx
3663 // (see announce_publication / send_discovery_datagram).
3664 let _ = self.spdp_unicast.send(t, &secured);
3665 }
3666 }
3667 }
3668 }
3669 // In-process discovery fastpath: see `announce_publication`.
3670 self.inproc_announce_subscription(data);
3671 Ok(())
3672 }
3673
3674 /// Re-announces the local SEDP endpoint records (publications +
3675 /// subscriptions) to a peer whose crypto-token exchange has just
3676 /// completed. Background: under `rtps_protection`/`discovery_
3677 /// protection` ZeroDDS wraps the SEDP message-/submessage-protected; the
3678 /// peer discards the initial SEDP burst UNTIL it has our participant crypto
3679 /// token (via Volatile). From that moment it can decode — a
3680 /// one-time re-announce brings the previously dropped SEDP up (mints fresh
3681 /// SNs; the reliable SEDP writer delivers them, HEARTBEAT/NACK retry covers a
3682 /// not-quite-ready peer timing). Once per peer (dedup).
3683 ///
3684 /// No-op without active rtps_/discovery_protection (then the announce
3685 /// went through plaintext anyway) and for already re-announced peers. Emits
3686 /// the RETAINED records directly (NO additional `announced_pubs` push).
3687 #[cfg(feature = "security")]
3688 fn re_announce_sedp_to_peer(&self, peer_prefix: GuidPrefix) {
3689 let Some(gate) = &self.config.security else {
3690 return;
3691 };
3692 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3693 let disc =
3694 gate.discovery_protection().unwrap_or(ProtectionLevel::None) != ProtectionLevel::None;
3695 if !rtps && !disc {
3696 return;
3697 }
3698 // First check whether we have any local endpoints at all — the token
3699 // exchange can complete BEFORE the user endpoint creation.
3700 // Without records do NOT mark as "re-announced" (the periodic tick
3701 // retriggers as soon as the user writer/reader is announced).
3702 let pubs = self
3703 .announced_pubs
3704 .lock()
3705 .map(|v| v.clone())
3706 .unwrap_or_default();
3707 let subs = self
3708 .announced_subs
3709 .lock()
3710 .map(|v| v.clone())
3711 .unwrap_or_default();
3712 if pubs.is_empty() && subs.is_empty() {
3713 return;
3714 }
3715 {
3716 let mut set = match self.sedp_reannounced.write() {
3717 Ok(s) => s,
3718 Err(_) => return,
3719 };
3720 if !set.insert(peer_prefix.0) {
3721 return; // already re-announced
3722 }
3723 }
3724 let send_dgs = |dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>| {
3725 for dg in dgs {
3726 if let Some(secured) = secure_outbound_bytes(self, &dg.bytes) {
3727 for t in dg.targets.iter() {
3728 if is_routable_user_locator(t) {
3729 let _ = self.spdp_unicast.send(t, &secured);
3730 }
3731 }
3732 }
3733 }
3734 };
3735 for data in &pubs {
3736 let shm = self.shm_locator(data.key.entity_id);
3737 let dgs = {
3738 let Ok(mut sedp) = self.sedp.lock() else {
3739 continue;
3740 };
3741 self.sync_sedp_discovery_protected(&mut sedp);
3742 let res = if let Some(ref bytes) = shm {
3743 sedp.announce_publication_with_shm_locator(data, bytes)
3744 } else {
3745 sedp.announce_publication(data)
3746 };
3747 match res {
3748 Ok(d) => d,
3749 Err(_) => continue,
3750 }
3751 };
3752 send_dgs(dgs);
3753 }
3754 for data in &subs {
3755 let dgs = {
3756 let Ok(mut sedp) = self.sedp.lock() else {
3757 continue;
3758 };
3759 self.sync_sedp_discovery_protected(&mut sedp);
3760 match sedp.announce_subscription(data) {
3761 Ok(d) => d,
3762 Err(_) => continue,
3763 }
3764 };
3765 send_dgs(dgs);
3766 }
3767 }
3768
3769 /// Own participant data as a `DiscoveredParticipant` — the
3770 /// self-view that the in-process fastpath hands to peers.
3771 fn self_as_discovered_participant(&self) -> zerodds_discovery::spdp::DiscoveredParticipant {
3772 // From the LIVE SPDP beacon: after `enable_security_builtins_with_auth`
3773 // it carries the `identity_token` + the secure endpoint bits that the
3774 // `participant_data` construction snapshot does NOT have. Without these the
3775 // in-process injected DP is worthless for the security handshake trigger
3776 // (`handle_remote_endpoints`/`begin_handshake_with` need
3777 // the token). Fallback to `participant_data` on lock poisoning.
3778 let data = self
3779 .spdp_beacon
3780 .lock()
3781 .map(|b| b.data.clone())
3782 .unwrap_or_else(|_| self.participant_data.clone());
3783 zerodds_discovery::spdp::DiscoveredParticipant {
3784 sender_prefix: self.guid_prefix,
3785 sender_vendor: VendorId::ZERODDS,
3786 data,
3787 }
3788 }
3789
3790 /// In-process discovery: injects the just-announced publication
3791 /// synchronously into all same-process+domain peer runtimes.
3792 fn inproc_announce_publication(
3793 &self,
3794 data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3795 ) {
3796 let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
3797 let mut dp = None;
3798 for peer in peers {
3799 if peer.guid_prefix == self.guid_prefix {
3800 continue;
3801 }
3802 let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
3803 peer.inproc_inject_publication(dp, data);
3804 }
3805 }
3806
3807 /// In-process discovery: injects the just-announced subscription
3808 /// synchronously into all same-process+domain peer runtimes.
3809 fn inproc_announce_subscription(
3810 &self,
3811 data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3812 ) {
3813 let peers = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
3814 let mut dp = None;
3815 for peer in peers {
3816 if peer.guid_prefix == self.guid_prefix {
3817 continue;
3818 }
3819 let dp = dp.get_or_insert_with(|| self.self_as_discovered_participant());
3820 peer.inproc_inject_subscription(dp, data);
3821 }
3822 }
3823
3824 /// In-process discovery (receive side): wires the remote
3825 /// participant + injects the publication into the SEDP cache and
3826 /// matches the local readers. Idempotent — an announcement arriving
3827 /// later via UDP is thereby a no-op.
3828 fn inproc_inject_publication(
3829 self: &Arc<Self>,
3830 dp: &zerodds_discovery::spdp::DiscoveredParticipant,
3831 data: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
3832 ) {
3833 // §2.2.2.2.1.17: an ignored publication/participant must not be matched.
3834 // The in-process fastpath bypasses the wire match path, so the ignore
3835 // filter must be honored here too — otherwise the Durability-Service's
3836 // own two participants (ingest + replay, same process) would match and
3837 // echo-loop despite mutually ignoring each other.
3838 if let Some(filter) = self.ignore_filter_snapshot() {
3839 let pub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
3840 let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
3841 if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
3842 return;
3843 }
3844 }
3845 let now = self.start_instant.elapsed();
3846 let is_new = self
3847 .discovered
3848 .lock()
3849 .map(|mut c| c.insert(dp.clone()))
3850 .unwrap_or(false);
3851 if let Ok(mut sedp) = self.sedp.lock() {
3852 if is_new {
3853 sedp.on_participant_discovered(dp);
3854 }
3855 sedp.cache_mut().insert_publication(data.clone(), now);
3856 }
3857 run_matching_pass(self);
3858 }
3859
3860 /// In-process discovery (receive side): like `inproc_inject_publication`
3861 /// for a subscription.
3862 fn inproc_inject_subscription(
3863 self: &Arc<Self>,
3864 dp: &zerodds_discovery::spdp::DiscoveredParticipant,
3865 data: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
3866 ) {
3867 // See `inproc_inject_publication`: honor the ignore filter on the
3868 // in-process fastpath (symmetric, subscription side).
3869 if let Some(filter) = self.ignore_filter_snapshot() {
3870 let sub_h = crate::instance_handle::InstanceHandle::from_guid(data.key);
3871 let part_h = crate::instance_handle::InstanceHandle::from_guid(data.participant_key);
3872 if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
3873 return;
3874 }
3875 }
3876 let now = self.start_instant.elapsed();
3877 let is_new = self
3878 .discovered
3879 .lock()
3880 .map(|mut c| c.insert(dp.clone()))
3881 .unwrap_or(false);
3882 if let Ok(mut sedp) = self.sedp.lock() {
3883 if is_new {
3884 sedp.on_participant_discovered(dp);
3885 }
3886 sedp.cache_mut().insert_subscription(data.clone(), now);
3887 }
3888 run_matching_pass(self);
3889 }
3890
3891 /// Snapshot of our own endpoints for the `pull-on-creation` path
3892 /// of a peer runtime starting later in the same process.
3893 fn inproc_snapshot(
3894 &self,
3895 ) -> (
3896 zerodds_discovery::spdp::DiscoveredParticipant,
3897 Vec<zerodds_rtps::publication_data::PublicationBuiltinTopicData>,
3898 Vec<zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData>,
3899 ) {
3900 let dp = self.self_as_discovered_participant();
3901 let pubs = self
3902 .announced_pubs
3903 .lock()
3904 .map(|v| v.clone())
3905 .unwrap_or_default();
3906 let subs = self
3907 .announced_subs
3908 .lock()
3909 .map(|v| v.clone())
3910 .unwrap_or_default();
3911 (dp, pubs, subs)
3912 }
3913
3914 /// At runtime creation: ask existing same-process+domain peers
3915 /// for their already-announced endpoints and inject these into
3916 /// our SEDP cache. Symmetric counterpart to the
3917 /// announce hook (which distributes live endpoints to peers).
3918 fn inproc_pull_from_peers(self: &Arc<Self>) {
3919 let peers: Vec<Arc<DcpsRuntime>> =
3920 crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
3921 .into_iter()
3922 .filter(|rt| rt.guid_prefix != self.guid_prefix)
3923 .collect();
3924 for peer in peers {
3925 let (dp, pubs, subs) = peer.inproc_snapshot();
3926 for p in &pubs {
3927 self.inproc_inject_publication(&dp, p);
3928 }
3929 for s in &subs {
3930 self.inproc_inject_subscription(&dp, s);
3931 }
3932 }
3933 }
3934
3935 /// FU2 S3: in-process counterpart to the security part of
3936 /// [`handle_spdp_datagram`]. Wires the secure builtin endpoints of the
3937 /// discovered peer and kicks off — if it announces an `identity_token`
3938 /// — the auth handshake; the resulting AUTH datagrams
3939 /// go to the peer via UDP **unicast** (reliable loopback).
3940 /// No-op without a local security stack or without a peer `identity_token`.
3941 #[cfg(feature = "security")]
3942 fn inproc_drive_security_handshake(
3943 self: &Arc<Self>,
3944 dp: &zerodds_discovery::spdp::DiscoveredParticipant,
3945 ) {
3946 if dp.sender_prefix == self.guid_prefix {
3947 return;
3948 }
3949 let Some(sec) = self.security_builtin_snapshot() else {
3950 return;
3951 };
3952 let dgs = if let Ok(mut s) = sec.lock() {
3953 s.note_remote_vendor(dp.sender_prefix, dp.sender_vendor);
3954 s.handle_remote_endpoints(dp);
3955 match dp.data.identity_token.as_ref() {
3956 Some(token) => s
3957 .begin_handshake_with(dp.sender_prefix, dp.data.guid.to_bytes(), token)
3958 .unwrap_or_default(),
3959 None => Vec::new(),
3960 }
3961 } else {
3962 Vec::new()
3963 };
3964 for dg in dgs {
3965 send_discovery_datagram(self, &dg.targets, &dg.bytes);
3966 }
3967 }
3968
3969 /// FU2 S3: in-process SPDP **participant** discovery. This was the real
3970 /// gap — `inproc_inject_publication`/`_subscription` only inject
3971 /// SEDP endpoints, the SPDP participant level (identity_token +
3972 /// `begin_handshake_with`) ran EXCLUSIVELY over the multicast path
3973 /// that is flaky on the codepit LXC. This hook, on activation of the
3974 /// security builtins, exchanges the participant DPs (with token) **bidirectionally** with
3975 /// all same-process+domain peers and kicks off the auth handshakes
3976 /// — deterministically, without a single multicast beacon.
3977 #[cfg(feature = "security")]
3978 fn inproc_announce_participant(self: &Arc<Self>) {
3979 let self_dp = self.self_as_discovered_participant();
3980 let peers: Vec<Arc<DcpsRuntime>> =
3981 crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group)
3982 .into_iter()
3983 .filter(|rt| rt.guid_prefix != self.guid_prefix)
3984 .collect();
3985 for peer in peers {
3986 // self → peer: the peer discovers US + triggers its handshake.
3987 let _ = peer
3988 .discovered
3989 .lock()
3990 .map(|mut c| c.insert(self_dp.clone()));
3991 peer.inproc_drive_security_handshake(&self_dp);
3992 // peer → self: WE discover the peer + trigger our handshake.
3993 let peer_dp = peer.self_as_discovered_participant();
3994 let _ = self
3995 .discovered
3996 .lock()
3997 .map(|mut c| c.insert(peer_dp.clone()));
3998 self.inproc_drive_security_handshake(&peer_dp);
3999 }
4000 }
4001
4002 /// C1 multicast-free discovery: sends a (possibly already security-
4003 /// transformed) SPDP beacon additionally to all configured
4004 /// unicast initial peers. No-op without peers → pure multicast behavior,
4005 /// no additional syscalls by default.
4006 fn send_spdp_to_initial_peers(&self, bytes: &[u8]) {
4007 for peer in &self.config.initial_peers {
4008 let _ = self.spdp_mc_tx.send(peer, bytes);
4009 }
4010 }
4011
4012 /// FU2 S3: sends an SPDP beacon IMMEDIATELY via multicast, instead of waiting
4013 /// for the next periodic `spdp_period` tick. Critical for the
4014 /// cross-process secured handshake: `DcpsRuntime::start` starts the
4015 /// beacon sender, whose first beacon (token-LESS) goes out BEFORE
4016 /// `enable_security_builtins_with_auth` sets the `identity_token` on the beacon.
4017 /// If a peer latches this token-less first beacon, it calls
4018 /// `begin_handshake_with` with `token=None` → no-op → the handshake NEVER
4019 /// starts. An immediate re-announce after setting the token ensures
4020 /// that the first token-carrying beacon goes out promptly.
4021 #[cfg(feature = "security")]
4022 fn announce_spdp_now(&self) {
4023 let mc_target = Locator {
4024 kind: LocatorKind::UdpV4,
4025 port: u32::from(
4026 u16::try_from(spdp_multicast_port(self.domain_id as u32)).unwrap_or(7400),
4027 ),
4028 address: {
4029 let mut a = [0u8; 16];
4030 a[12..].copy_from_slice(&self.config.spdp_multicast_group.octets());
4031 a
4032 },
4033 };
4034 if let Ok(mut beacon) = self.spdp_beacon.lock() {
4035 if let Ok(datagram) = beacon.serialize() {
4036 if let Some(secured) = secure_outbound_bytes(self, &datagram) {
4037 let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4038 // C1 multicast-free discovery: on the immediate announce too, to
4039 // the configured initial peers (ZERODDS_PEERS).
4040 self.send_spdp_to_initial_peers(&secured);
4041 // Directed unicast fan-out to already-discovered peers:
4042 // covers the order in which we discover a peer
4043 // BEFORE our security builtins (token) are active — then the
4044 // directed response in handle_spdp_datagram skipped tokenless;
4045 // announce_spdp_now() (called by enable() after the token set)
4046 // catches up with the tokened beacon promptly + LXC-multicast-
4047 // independently. Otherwise the peer waits until spdp_period.
4048 for loc in wlp_unicast_targets(&self.discovered_participants()) {
4049 let _ = self.spdp_unicast.send(&loc, &secured);
4050 }
4051 }
4052 }
4053 // FastDDS interop: additionally announce on the reliable secure SPDP
4054 // writer (0xff0101c2), so FastDDS sees our full secured
4055 // participant data over its expected channel.
4056 if self.config.enable_secure_spdp {
4057 if let Ok(datagram) = beacon.serialize_secure() {
4058 let protected = protect_secure_spdp(self, &datagram).unwrap_or(datagram);
4059 if let Some(secured) = secure_outbound_bytes(self, &protected) {
4060 let _ = self.spdp_mc_tx.send(&mc_target, &secured);
4061 }
4062 }
4063 }
4064 }
4065 }
4066
4067 /// FU2 cross-vendor: `EndpointSecurityInfo` (PID_ENDPOINT_SECURITY_INFO,
4068 /// 0x1004) for user endpoints, derived from the governance
4069 /// `data_protection`. Foreign vendors (cyclone/FastDDS) reject, with
4070 /// `data_protection=ENCRYPT`, a user endpoint WITHOUT this PID as
4071 /// non-secure ("Non secure remote ... not allowed by security").
4072 /// `None` without an active security gate (plain).
4073 #[cfg(feature = "security")]
4074 fn user_endpoint_security_info(
4075 &self,
4076 ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4077 let gate = self.config.security.as_ref()?;
4078 let meta = gate.metadata_protection().ok()?;
4079 let data = gate.data_protection().ok()?;
4080 let disc = gate.topic_discovery_protected().unwrap_or(false);
4081 let liv = gate
4082 .liveliness_protection()
4083 .map(|l| l != ProtectionLevel::None)
4084 .unwrap_or(false);
4085 let rdp = gate.topic_read_protected().unwrap_or(false);
4086 let wrp = gate.topic_write_protected().unwrap_or(false);
4087 Some(compute_user_endpoint_attrs(meta, data, disc, liv, rdp, wrp))
4088 }
4089
4090 #[cfg(not(feature = "security"))]
4091 fn user_endpoint_security_info(
4092 &self,
4093 ) -> Option<zerodds_rtps::endpoint_security_info::EndpointSecurityInfo> {
4094 None
4095 }
4096
4097 /// Registers a local user writer. The caller gets the
4098 /// writer `EntityId`; for sends via `write_user_sample(eid, ...)`.
4099 ///
4100 /// In the runtime there is **still no** automatic SEDP announce +
4101 /// matching — that comes in B4b. Currently `register_user_writer`
4102 /// is just the wiring.
4103 ///
4104 /// # Errors
4105 /// `PreconditionNotMet` if the registry mutex is poisoned.
4106 pub fn register_user_writer(&self, cfg: UserWriterConfig) -> Result<EntityId> {
4107 // Default: WithKey. Backward-compat for all test callers.
4108 self.register_user_writer_kind(cfg, true)
4109 }
4110
4111 /// Like [`register_user_writer`] but with an explicit NoKey/WithKey
4112 /// flag. Cross-vendor interop needs it: if the IDL type has no
4113 /// `@key`, the writer MUST set `is_keyed=false`, otherwise
4114 /// a remote reader rejects the DATA submessage due to an
4115 /// entityKind mismatch (Spec §9.3.1.2 table 9.1: 0x02=WithKey
4116 /// vs 0x03=NoKey).
4117 pub fn register_user_writer_kind(
4118 &self,
4119 cfg: UserWriterConfig,
4120 is_keyed: bool,
4121 ) -> Result<EntityId> {
4122 let now = self.start_instant.elapsed();
4123 let key = self.next_entity_key();
4124 let eid = if is_keyed {
4125 EntityId::user_writer_with_key(key)
4126 } else {
4127 EntityId::user_writer_no_key(key)
4128 };
4129 let writer = ReliableWriter::new(ReliableWriterConfig {
4130 guid: Guid::new(self.guid_prefix, eid),
4131 vendor_id: VendorId::ZERODDS,
4132 reader_proxies: Vec::new(),
4133 max_samples: 1024,
4134 history_kind: HistoryKind::KeepLast { depth: 32 },
4135 heartbeat_period: DEFAULT_HEARTBEAT_PERIOD,
4136 // Ethernet-safe default; the value is raised at the reader match
4137 // if all readers are same-host (see the
4138 // set_fragmentation call after add_reader_proxy).
4139 fragment_size: DEFAULT_FRAGMENT_SIZE,
4140 mtu: DEFAULT_MTU,
4141 });
4142 let mut pub_data = build_publication_data(
4143 self.guid_prefix,
4144 eid,
4145 &cfg,
4146 &self.config.data_representation_offer,
4147 self.user_announce_locator,
4148 );
4149 // FU2 cross-vendor: EndpointSecurityInfo from the governance
4150 // data_protection — otherwise cyclone/FastDDS reject the user endpoint
4151 // with data_protection=ENCRYPT as non-secure.
4152 pub_data.security_info = self.user_endpoint_security_info();
4153 self.user_writers
4154 .write()
4155 .map_err(|_| DdsError::PreconditionNotMet {
4156 reason: "user_writers poisoned",
4157 })?
4158 .insert(
4159 eid,
4160 Arc::new(Mutex::new(UserWriterSlot {
4161 writer,
4162 topic_name: cfg.topic_name.clone(),
4163 type_name: cfg.type_name.clone(),
4164 reliable: cfg.reliable,
4165 durability: cfg.durability,
4166 deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4167 // Initial `None`: the deadline window starts only on the
4168 // first real write. Prevents false misses due to
4169 // slow entity setup (e.g. Linux CI container)
4170 // before the app does its first write(). On the
4171 // first write() `last_write = Some(now)` is set,
4172 // and from then the deadline counter ticks.
4173 last_write: None,
4174 offered_deadline_missed_count: 0,
4175 liveliness_lost_count: 0,
4176 last_liveliness_assert: Some(now),
4177 offered_incompatible_qos: crate::status::OfferedIncompatibleQosStatus::default(
4178 ),
4179 lifespan_nanos: qos_duration_to_nanos(cfg.lifespan.duration),
4180 sample_insert_times: alloc::collections::VecDeque::new(),
4181 liveliness_kind: cfg.liveliness.kind,
4182 liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4183 ownership: cfg.ownership,
4184 ownership_strength: cfg.ownership_strength,
4185 partition: cfg.partition.clone(),
4186 #[cfg(feature = "security")]
4187 reader_protection: BTreeMap::new(),
4188 #[cfg(feature = "security")]
4189 locator_to_peer: BTreeMap::new(),
4190 type_identifier: cfg.type_identifier.clone(),
4191 data_rep_offer_override: cfg.data_representation_offer.clone(),
4192 // Default FINAL: irrelevant for XCDR1 (default offer)
4193 // (final==appendable==CDR_LE), correct for XCDR2 for
4194 // @final types. Appendable/mutable types set this later via
4195 // set_user_writer_wire_extensibility.
4196 wire_extensibility: zerodds_types::qos::ExtensibilityForRepr::Final,
4197 durability_backend: None,
4198 backend_primed: false,
4199 })),
4200 );
4201 // FIRST match locally, THEN announce — symmetric to
4202 // register_user_reader_kind. Avoids a peer-side match
4203 // triggered by our announce_publication
4204 // starting a data flow to us before we have wired the
4205 // ReaderProxies.
4206 self.match_local_writer_against_cache(eid);
4207 let _ = self.announce_publication(&pub_data);
4208 // Intra-runtime routing: scan local readers for a match on
4209 // (topic, type). Applies to bridge daemons with writer+reader in
4210 // the same runtime (WS/MQTT/CoAP/AMQP bridges). Without this
4211 // route the local reader gets no samples from the local
4212 // writer — the `inproc` fastpath explicitly skips self, UDP loopback
4213 // is not guaranteed, and SEDP match paths go via
4214 // the discovered cache, which does not contain self.
4215 self.recompute_intra_runtime_routes();
4216 // FU2 F-ECHO-WRITE: a user writer created AFTER handshake completion
4217 // (e.g. the event-driven echo writer in the responder/pong) must send its
4218 // per-endpoint datawriter_crypto_tokens IMMEDIATELY to the already-
4219 // authenticated peers — not only on the next tick. Otherwise
4220 // cyclone's reader stays in "waiting for approval by security" beyond
4221 // its match deadline (the event-driven pong may not tick
4222 // in time) → flaky sub=0. Idempotent via endpoint_tokens_sent dedup.
4223 #[cfg(feature = "security")]
4224 self.flush_late_endpoint_tokens();
4225 // Observability event.
4226 self.config.observability.record(
4227 &zerodds_foundation::observability::Event::new(
4228 zerodds_foundation::observability::Level::Info,
4229 zerodds_foundation::observability::Component::Dcps,
4230 "user_writer.created",
4231 )
4232 .with_attr("topic", cfg.topic_name.as_str())
4233 .with_attr("type", cfg.type_name.as_str())
4234 .with_attr("reliable", if cfg.reliable { "true" } else { "false" }),
4235 );
4236 Ok(eid)
4237 }
4238
4239 /// FU2 F-ECHO-WRITE: sends pending per-endpoint crypto tokens IMMEDIATELY to all
4240 /// already-authenticated peers. For user endpoints created AFTER handshake
4241 /// completion (event-driven echo writer in the responder): their token
4242 /// must go out before cyclone's reader match deadline expires — the periodic
4243 /// tick (or a VolatileSecure recv) is otherwise possibly too late. Idempotent
4244 /// via `endpoint_tokens_sent` dedup (double-send with the tick excluded).
4245 #[cfg(feature = "security")]
4246 fn flush_late_endpoint_tokens(&self) {
4247 let Some(stack) = self.security_builtin_snapshot() else {
4248 return;
4249 };
4250 let Ok(mut s) = stack.lock() else {
4251 return;
4252 };
4253 let now = self.start_instant.elapsed();
4254 let peers: alloc::vec::Vec<GuidPrefix> = self
4255 .config
4256 .security
4257 .as_ref()
4258 .map(|g| {
4259 g.authenticated_peer_prefixes()
4260 .into_iter()
4261 .map(GuidPrefix::from_bytes)
4262 .collect()
4263 })
4264 .unwrap_or_default();
4265 for prefix in peers {
4266 let already = self
4267 .endpoint_tokens_sent
4268 .read()
4269 .map(|set| set.clone())
4270 .unwrap_or_default();
4271 let pending =
4272 pending_endpoint_tokens(prepare_endpoint_crypto_tokens(self, prefix), &already);
4273 for ep_msg in pending {
4274 let key = endpoint_token_key(&ep_msg);
4275 let dgs = protect_volatile_outbound(
4276 self,
4277 prefix,
4278 s.volatile_writer
4279 .write_with_heartbeat(&ep_msg, now)
4280 .unwrap_or_default(),
4281 );
4282 for dg in dgs {
4283 for t in dg.targets.iter() {
4284 let _ = self.spdp_unicast.send(t, &dg.bytes);
4285 }
4286 }
4287 if let Ok(mut set) = self.endpoint_tokens_sent.write() {
4288 set.insert(key);
4289 }
4290 }
4291 // Periodic re-announce retrigger: as soon as the user writer/reader
4292 // is announced (announced_pubs/subs not empty), this catches up the
4293 // SEDP initially dropped under rtps_/discovery_protection to this
4294 // (now tokened) peer. Once per peer (dedup in the method).
4295 self.re_announce_sedp_to_peer(prefix);
4296 }
4297 }
4298
4299 /// Spec §2.2.3.5 — registers a durability-service backend on
4300 /// a writer already registered via [`register_user_writer`].
4301 /// With Durability=Transient/Persistent the backend is replayed into the
4302 /// HistoryCache on the first late-joiner match in
4303 /// `wire_writer_to_remote_reader`, so the reader gets all samples —
4304 /// including those no longer in the writer cache due to history eviction
4305 /// or those that have survived a writer restart.
4306 pub fn attach_durability_backend(
4307 &self,
4308 eid: EntityId,
4309 backend: alloc::sync::Arc<dyn crate::durability_service::DurabilityBackend>,
4310 ) -> Result<()> {
4311 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4312 what: "attach_durability_backend: unknown writer entity id",
4313 })?;
4314 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4315 reason: "user_writer slot poisoned",
4316 })?;
4317 slot.durability_backend = Some(backend);
4318 slot.backend_primed = false;
4319 Ok(())
4320 }
4321
4322 /// Sets the type extensibility of a writer (FINAL/APPENDABLE/
4323 /// MUTABLE). Affects exclusively the encapsulation header
4324 /// of the user payload (see [`user_payload_encap`]) — relevant for
4325 /// XCDR2 wire, where @appendable requires a `D_CDR2_LE` and @mutable a
4326 /// `PL_CDR2_LE` header. The codegen/FFI calls this after
4327 /// `register_user_writer*` when the type is not @final.
4328 /// Does NOT change the SEDP announce offer list.
4329 ///
4330 /// # Errors
4331 /// `BadParameter` on an unknown EntityId, `PreconditionNotMet` on a
4332 /// poisoned slot mutex.
4333 pub fn set_user_writer_wire_extensibility(
4334 &self,
4335 eid: EntityId,
4336 ext: zerodds_types::qos::ExtensibilityForRepr,
4337 ) -> Result<()> {
4338 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
4339 what: "set_user_writer_wire_extensibility: unknown writer entity id",
4340 })?;
4341 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
4342 reason: "user_writer slot poisoned",
4343 })?;
4344 slot.wire_extensibility = ext;
4345 Ok(())
4346 }
4347
4348 /// Registers a local user reader. Returns the reader EntityId
4349 /// and an `mpsc::Receiver` through which DataReader handles
4350 /// consume incoming samples.
4351 ///
4352 /// # Errors
4353 /// `PreconditionNotMet` if the registry mutex is poisoned.
4354 /// Registers a user reader. Returns the EntityId and an
4355 /// `mpsc::Receiver<UserSample>` — alive samples deliver payload,
4356 /// lifecycle markers carry key hash + ChangeKind.
4357 pub fn register_user_reader(
4358 &self,
4359 cfg: UserReaderConfig,
4360 ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4361 // Default: WithKey. Backward-compat for all test callers.
4362 self.register_user_reader_kind(cfg, true)
4363 }
4364
4365 /// Like [`register_user_reader`] but with an explicit NoKey/WithKey
4366 /// flag. Symmetric to [`register_user_writer_kind`] — the reader kind
4367 /// must match the writer kind.
4368 pub fn register_user_reader_kind(
4369 &self,
4370 cfg: UserReaderConfig,
4371 is_keyed: bool,
4372 ) -> Result<(EntityId, mpsc::Receiver<UserSample>)> {
4373 let now = self.start_instant.elapsed();
4374 let key = self.next_entity_key();
4375 let eid = if is_keyed {
4376 EntityId::user_reader_with_key(key)
4377 } else {
4378 EntityId::user_reader_no_key(key)
4379 };
4380 let reader = ReliableReader::new(ReliableReaderConfig {
4381 guid: Guid::new(self.guid_prefix, eid),
4382 vendor_id: VendorId::ZERODDS,
4383 writer_proxies: Vec::new(),
4384 max_samples_per_proxy: 256,
4385 // D.5e: 0ms = synchronous ACK response (Cyclone parity).
4386 // Previously 200ms = pre-1.0 default without spec justification.
4387 heartbeat_response_delay:
4388 zerodds_rtps::reliable_reader::DEFAULT_HEARTBEAT_RESPONSE_DELAY,
4389 // C3: ROS-realistic reassembly cap (PointCloud2/Image),
4390 // instead of the conservative rtps 1-MiB default.
4391 assembler_caps: AssemblerCaps {
4392 max_sample_bytes: self.config.max_reassembly_sample_bytes,
4393 ..AssemblerCaps::default()
4394 },
4395 });
4396 let (tx, rx) = mpsc::channel();
4397 let mut sub_data = build_subscription_data(
4398 self.guid_prefix,
4399 eid,
4400 &cfg,
4401 &self.config.data_representation_offer,
4402 self.user_announce_locator,
4403 );
4404 // FU2 cross-vendor: EndpointSecurityInfo from the governance (see writer).
4405 sub_data.security_info = self.user_endpoint_security_info();
4406 self.user_readers
4407 .write()
4408 .map_err(|_| DdsError::PreconditionNotMet {
4409 reason: "user_readers poisoned",
4410 })?
4411 .insert(
4412 eid,
4413 Arc::new(Mutex::new(UserReaderSlot {
4414 reader,
4415 topic_name: cfg.topic_name.clone(),
4416 type_name: cfg.type_name.clone(),
4417 sample_tx: tx,
4418 async_waker: Arc::new(std::sync::Mutex::new(None)),
4419 listener: None,
4420 durability: cfg.durability,
4421 deadline_nanos: qos_duration_to_nanos(cfg.deadline.period),
4422 // Start time as reference (see register_user_writer).
4423 last_sample_received: Some(now),
4424 requested_deadline_missed_count: 0,
4425 requested_incompatible_qos:
4426 crate::status::RequestedIncompatibleQosStatus::default(),
4427 sample_lost_count: 0,
4428 sample_rejected: crate::status::SampleRejectedStatus::default(),
4429 samples_delivered_count: 0,
4430 liveliness_lease_nanos: qos_duration_to_nanos(cfg.liveliness.lease_duration),
4431 liveliness_kind: cfg.liveliness.kind,
4432 liveliness_alive_count: 0,
4433 liveliness_not_alive_count: 0,
4434 // Optimistic init: we see the writer via SEDP,
4435 // until the lease expires it counts as alive.
4436 liveliness_alive: true,
4437 ownership: cfg.ownership,
4438 partition: cfg.partition.clone(),
4439 writer_strengths: alloc::collections::BTreeMap::new(),
4440 type_identifier: cfg.type_identifier.clone(),
4441 type_consistency: cfg.type_consistency,
4442 })),
4443 );
4444 // FIRST match locally (create the writer proxy on the reader),
4445 // THEN announce. Otherwise our announce_subscription triggers a
4446 // backend replay at the peer via the in-process fastpath
4447 // (Spec §2.2.3.5), which injects DATA into *our* reader
4448 // before we have wired the matching WriterProxies — the
4449 // samples are then discarded as unknown-source
4450 // (tests `{transient,persistent}_late_joiner_receives_backend_replay`).
4451 self.match_local_reader_against_cache(eid);
4452 let _ = self.announce_subscription(&sub_data);
4453 // Intra-runtime routing: see `register_user_writer_kind`.
4454 self.recompute_intra_runtime_routes();
4455 // Observability event.
4456 self.config.observability.record(
4457 &zerodds_foundation::observability::Event::new(
4458 zerodds_foundation::observability::Level::Info,
4459 zerodds_foundation::observability::Component::Dcps,
4460 "user_reader.created",
4461 )
4462 .with_attr("topic", cfg.topic_name.as_str())
4463 .with_attr("type", cfg.type_name.as_str()),
4464 );
4465 Ok((eid, rx))
4466 }
4467
4468 /// Rebuilds the same-runtime writer→reader routing table.
4469 /// Called in `register_user_writer_kind` and `register_user_reader_kind`
4470 /// after every endpoint create. Per local writer it collects
4471 /// all local readers that have exactly the same `topic_name`
4472 /// and `type_name`. The lookup in the write hot path
4473 /// (`write_user_sample_borrowed`) is read-locked and cheap
4474 /// (BTreeMap lookup → Vec clone). On endpoint removal (TODO: not
4475 /// yet hooked everywhere) this would be called too.
4476 fn recompute_intra_runtime_routes(&self) {
4477 let writer_snap = self.writer_slots_snapshot();
4478 let reader_snap = self.reader_slots_snapshot();
4479 let mut new_map: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
4480 for (writer_eid, w_arc) in writer_snap {
4481 let (w_topic, w_type) = match w_arc.lock() {
4482 Ok(s) => (s.topic_name.clone(), s.type_name.clone()),
4483 Err(_) => continue,
4484 };
4485 let mut readers: Vec<EntityId> = Vec::new();
4486 for (reader_eid, r_arc) in &reader_snap {
4487 let matches = match r_arc.lock() {
4488 Ok(s) => s.topic_name == w_topic && s.type_name == w_type,
4489 Err(_) => false,
4490 };
4491 if matches {
4492 readers.push(*reader_eid);
4493 }
4494 }
4495 if !readers.is_empty() {
4496 new_map.insert(writer_eid, readers);
4497 }
4498 }
4499 let changed = match self.intra_runtime_routes.write() {
4500 Ok(mut g) => {
4501 let changed = *g != new_map;
4502 *g = new_map;
4503 changed
4504 }
4505 Err(_) => false,
4506 };
4507 // A new/changed intra-runtime route is a same-participant
4508 // match → wake the `wait_for_matched_{subscription,publication}` waiter
4509 // (the matched count now includes these routes).
4510 if changed {
4511 self.match_event.1.notify_all();
4512 }
4513 }
4514
4515 /// Same-runtime direct dispatch: pushes the just-written
4516 /// sample directly into the `sample_tx` channel of all local readers
4517 /// on the same topic+type. Avoids an RTPS wire roundtrip + UDP
4518 /// loopback for the bridge-daemon case (writer+reader in the same
4519 /// `DcpsRuntime`). Called by the write hot path after the normal
4520 /// wire dispatch.
4521 fn intra_runtime_dispatch_alive(
4522 &self,
4523 writer_eid: EntityId,
4524 payload: &[u8],
4525 writer_strength: i32,
4526 ) {
4527 let routes: Vec<EntityId> = match self.intra_runtime_routes.read() {
4528 Ok(g) => match g.get(&writer_eid) {
4529 Some(v) => v.clone(),
4530 None => return,
4531 },
4532 Err(_) => return,
4533 };
4534 if routes.is_empty() {
4535 return;
4536 }
4537 let writer_guid = Guid::new(self.guid_prefix, writer_eid).to_bytes();
4538 for reader_eid in routes {
4539 let Some(slot_arc) = self.reader_slot(reader_eid) else {
4540 continue;
4541 };
4542 // Hold the slot lock only for the listener/sender clone, dispatch
4543 // outside (symmetric to the data-receive path above, which
4544 // preserves exactly the same order in the DATA arm).
4545 let listener;
4546 let waker;
4547 let sender;
4548 {
4549 let Ok(slot) = slot_arc.lock() else {
4550 continue;
4551 };
4552 listener = slot.listener.clone();
4553 waker = Arc::clone(&slot.async_waker);
4554 sender = slot.sample_tx.clone();
4555 }
4556 // Listener and MPSC are exclusive (see the data-arm comment):
4557 // if a listener is set, the sample only goes to it;
4558 // otherwise to the MPSC receiver.
4559 if let Some(l) = listener {
4560 // The listener signature is `(payload: &[u8], representation: u8)`.
4561 // Intra-runtime: no encap header, `0` = native.
4562 l(payload, 0);
4563 } else {
4564 let sample = UserSample::Alive {
4565 payload: crate::sample_bytes::SampleBytes::from_vec(payload.to_vec()),
4566 writer_guid,
4567 writer_strength,
4568 representation: 0,
4569 };
4570 let _ = sender.send(sample);
4571 wake_async_waker(&waker);
4572 }
4573 }
4574 }
4575
4576 /// On registration / SEDP event: for a local writer `eid`
4577 /// go through all subscriptions known in the cache; on a topic+type
4578 /// match add a `ReaderProxy` to the local ReliableWriter.
4579 fn match_local_writer_against_cache(&self, eid: EntityId) {
4580 let (topic, type_name) = {
4581 let Some(arc) = self.writer_slot(eid) else {
4582 return;
4583 };
4584 let Ok(s) = arc.lock() else {
4585 return;
4586 };
4587 (s.topic_name.clone(), s.type_name.clone())
4588 };
4589 let (matches, conflict): (Vec<_>, bool) = {
4590 let sedp = match self.sedp.lock() {
4591 Ok(s) => s,
4592 Err(_) => return,
4593 };
4594 let matches = sedp
4595 .cache()
4596 .match_subscriptions(&topic, &type_name)
4597 .map(|s| s.data.clone())
4598 .collect();
4599 let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
4600 (matches, conflict)
4601 };
4602 if conflict {
4603 self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
4604 }
4605 for sub in matches {
4606 self.wire_writer_to_remote_reader(eid, &sub);
4607 }
4608 }
4609
4610 fn match_local_reader_against_cache(&self, eid: EntityId) {
4611 let (topic, type_name) = {
4612 let Some(arc) = self.reader_slot(eid) else {
4613 return;
4614 };
4615 let Ok(s) = arc.lock() else {
4616 return;
4617 };
4618 (s.topic_name.clone(), s.type_name.clone())
4619 };
4620 let (matches, conflict): (Vec<_>, bool) = {
4621 let sedp = match self.sedp.lock() {
4622 Ok(s) => s,
4623 Err(_) => return,
4624 };
4625 let matches = sedp
4626 .cache()
4627 .match_publications(&topic, &type_name)
4628 .map(|p| p.data.clone())
4629 .collect();
4630 let conflict = sedp.cache().topic_name_conflicts(&topic, &type_name);
4631 (matches, conflict)
4632 };
4633 if conflict {
4634 self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
4635 }
4636 for pubd in matches {
4637 self.wire_reader_to_remote_writer(eid, &pubd);
4638 }
4639 }
4640
4641 fn wire_writer_to_remote_reader(
4642 &self,
4643 writer_eid: EntityId,
4644 sub: &zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData,
4645 ) {
4646 // §2.2.2.2.1.16: an ignored subscription must not be MATCHED (symmetric
4647 // to the publication gate in `wire_reader_to_remote_writer`). The
4648 // Durability-Service ignores its own ingest reader here so the replay
4649 // writer never delivers back to it (echo loop).
4650 if let Some(filter) = self.ignore_filter_snapshot() {
4651 let sub_h = crate::instance_handle::InstanceHandle::from_guid(sub.key);
4652 let part_h = crate::instance_handle::InstanceHandle::from_guid(sub.participant_key);
4653 if filter.is_subscription_ignored(sub_h) || filter.is_participant_ignored(part_h) {
4654 return;
4655 }
4656 }
4657 let locators =
4658 endpoint_or_default_locators(&sub.unicast_locators, sub.key.prefix, &self.discovered);
4659 if locators.is_empty() {
4660 return;
4661 }
4662 // Backend replay datagrams (Spec §2.2.3.5). Sent after
4663 // the slot-lock release, so the send path does not run under
4664 // the slot mutex.
4665 let mut replay_dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram> = Vec::new();
4666 if let Some(slot_arc) = self.writer_slot(writer_eid) {
4667 if let Ok(mut slot) = slot_arc.lock() {
4668 let slot = &mut *slot;
4669 // Idempotency gate: if a ReaderProxy already exists for this
4670 // remote reader, the match has already run
4671 // once. A re-wire (e.g. when the SEDP announcement
4672 // arrives at the writer both via the in-process fastpath and via UDP)
4673 // would REPLACE the proxy via
4674 // `add_reader_proxy` — and thereby reset
4675 // `highest_acked_sn`/`highest_sent_sn`.
4676 // The next tick then emits an invalid HEARTBEAT
4677 // with `first_sn > last_sn` (cache_min=N, highest_acked+1=N+1),
4678 // the reader interprets this as "everything before first_sn is
4679 // lost" and advances `delivered_up_to` past not-yet-
4680 // delivered backend replay samples (tests
4681 // `{transient,persistent}_late_joiner_receives_backend_replay`
4682 // — 3% flake without the gate).
4683 if slot
4684 .writer
4685 .reader_proxies()
4686 .iter()
4687 .any(|p| p.remote_reader_guid == sub.key)
4688 {
4689 return;
4690 }
4691 // --- QoS-Compatibility ---
4692 // Spec OMG DDS 1.4 §2.2.3.6: Writer offered >= Reader requested.
4693 //
4694 // Per reject, bump the responsible policy ID in
4695 // `offered_incompatible_qos.policies`, so the
4696 // DataWriter listener is triggered via `dispatch_offered_incompatible_qos`.
4697 // We track the *first* faulty
4698 // policy as `last_policy_id` (Spec §2.2.4.1: most-recent).
4699 use crate::psm_constants::qos_policy_id as qid;
4700 use crate::status::bump_policy_count;
4701 // C2 "loud instead of silent": an incompatible QoS match is
4702 // not only kept as a pollable status (Spec §2.2.4.1),
4703 // but logged loudly IMMEDIATELY. The central ROS-DDS
4704 // pain point is that QoS mismatches are silently discarded
4705 // (e.g. Cyclone's `DDS_INVALID_QOS_POLICY_ID` without a
4706 // log) — exactly that made the ROS-2 entityKind diagnosis so
4707 // expensive. The reject names the topic, remote reader and
4708 // the exact policy.
4709 let obs = self.config.observability.clone();
4710 let topic_for_log = slot.topic_name.clone();
4711 let remote_for_log = alloc::format!("{:?}", sub.key);
4712 let bump = |slot: &mut UserWriterSlot, pid: u32| {
4713 slot.offered_incompatible_qos.total_count =
4714 slot.offered_incompatible_qos.total_count.saturating_add(1);
4715 slot.offered_incompatible_qos.last_policy_id = pid;
4716 bump_policy_count(&mut slot.offered_incompatible_qos.policies, pid);
4717 obs.record(
4718 &zerodds_foundation::observability::Event::new(
4719 zerodds_foundation::observability::Level::Warn,
4720 zerodds_foundation::observability::Component::Dcps,
4721 "qos.incompatible.offered",
4722 )
4723 .with_attr("topic", topic_for_log.as_str())
4724 .with_attr("remote_reader", remote_for_log.as_str())
4725 .with_attr("policy", qos_policy_id_name(pid)),
4726 );
4727 };
4728
4729 // Durability rank: Volatile < TransientLocal < Transient <
4730 // Persistent. The writer may offer more than the reader requests.
4731 if (slot.durability as u8) < (sub.durability as u8) {
4732 bump(slot, qid::DURABILITY);
4733 return;
4734 }
4735 // Deadline: writer period <= reader period (the writer promises
4736 // to write faster than the reader expects).
4737 if !deadline_compat(
4738 slot.deadline_nanos,
4739 qos_duration_to_nanos(sub.deadline.period),
4740 ) {
4741 bump(slot, qid::DEADLINE);
4742 return;
4743 }
4744 // Liveliness-Kind: Automatic < ManualByParticipant < ManualByTopic.
4745 // Writer-Kind >= Reader-Kind. Lease: writer.lease <= reader.lease.
4746 if (slot.liveliness_kind as u8) < (sub.liveliness.kind as u8) {
4747 bump(slot, qid::LIVELINESS);
4748 return;
4749 }
4750 if !deadline_compat(
4751 slot.liveliness_lease_nanos,
4752 qos_duration_to_nanos(sub.liveliness.lease_duration),
4753 ) {
4754 bump(slot, qid::LIVELINESS);
4755 return;
4756 }
4757 // Ownership: both must be equal (Spec §2.2.3.6 Table:
4758 // no "compatible" case except exactly equal).
4759 if slot.ownership != sub.ownership {
4760 bump(slot, qid::OWNERSHIP);
4761 return;
4762 }
4763 // Partition: at least one common partition — or
4764 // both empty (default partition "").
4765 if !partitions_overlap(&slot.partition, &sub.partition) {
4766 bump(slot, qid::PARTITION);
4767 return;
4768 }
4769 // F-TYPES-3 XTypes-1.3 §7.6.3.7 symmetric writer-side check.
4770 // If both sides carry a TypeIdentifier (≠ None),
4771 // we check compatibility. The reader's TCE policy is not
4772 // directly available here; we take the default TCE
4773 // (AllowTypeCoercion without PreventWidening) — the reader-
4774 // side check in `wire_reader_to_remote_writer` validates
4775 // with the real reader TCE.
4776 if slot.type_identifier != zerodds_types::TypeIdentifier::None
4777 && sub.type_identifier != zerodds_types::TypeIdentifier::None
4778 {
4779 let registry = zerodds_types::resolve::TypeRegistry::new();
4780 let tce = zerodds_types::qos::TypeConsistencyEnforcement::default();
4781 let matcher = zerodds_types::type_matcher::TypeMatcher::new(&tce);
4782 if !matcher
4783 .match_types(&slot.type_identifier, &sub.type_identifier, ®istry)
4784 .is_match()
4785 {
4786 bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
4787 return;
4788 }
4789 }
4790
4791 let mut proxy = zerodds_rtps::reader_proxy::ReaderProxy::new(
4792 sub.key,
4793 locators.clone(),
4794 Vec::new(),
4795 slot.reliable,
4796 );
4797 // D.5g — Per-Peer DataRepresentation negotiation
4798 // (XTypes 1.3 §7.6.3.1.2). Writer-offered = Per-Writer-
4799 // Override (slot.data_rep_offer_override) ODER Runtime-
4800 // Default. Reader-accepted = sub.data_representation
4801 // (spec default `[XCDR1]` if empty). Match mode from
4802 // RuntimeConfig.
4803 {
4804 use zerodds_rtps::publication_data::data_representation as dr;
4805 let writer_offered: Vec<i16> = slot
4806 .data_rep_offer_override
4807 .clone()
4808 .unwrap_or_else(|| self.config.data_representation_offer.clone());
4809 let mode = self.config.data_rep_match_mode;
4810 if let Some(negotiated) =
4811 dr::negotiate(&writer_offered, &sub.data_representation, mode)
4812 {
4813 proxy.set_negotiated_data_representation(negotiated);
4814 } else {
4815 // No overlap → SEDP match spec violation.
4816 // We add the proxy anyway for best-effort
4817 // compat; the wire-format default stays XCDR2.
4818 // A spec-strict caller should reject the match.
4819 }
4820 }
4821 // Spec §2.2.3.4 Tab. 16: cache replay suppression. For
4822 // Volatile the reader must not see any late-joiner history
4823 // → skip up to `cache.max_sn`. For Transient/Persistent
4824 // the backend is authoritative — we deliver the history
4825 // via the backend replay path with NEW SNs; the
4826 // writer's own cache (especially gappy under KeepLast
4827 // eviction) must not serve the reader twice.
4828 // TransientLocal is the only tier where the
4829 // writer cache is the real history anchor.
4830 if !matches!(slot.durability, zerodds_qos::DurabilityKind::TransientLocal) {
4831 if let Some(max) = slot.writer.cache().max_sn() {
4832 proxy.skip_samples_up_to(max);
4833 }
4834 }
4835 // Spec §2.2.3.5 — Durability=Transient/Persistent:
4836 // on the first late-joiner match, re-inject the backend samples
4837 // into the HistoryCache. The existing
4838 // reliable-reader path then delivers them via DATA +
4839 // heartbeat/AckNack. Idempotent via the
4840 // `backend_primed` flag.
4841 let backend_writes: Vec<Vec<u8>> = if !slot.backend_primed
4842 && (slot.durability == zerodds_qos::DurabilityKind::Transient
4843 || slot.durability == zerodds_qos::DurabilityKind::Persistent)
4844 {
4845 slot.durability_backend
4846 .as_ref()
4847 .and_then(|b| b.replay_for_topic(&slot.topic_name).ok())
4848 .unwrap_or_default()
4849 .into_iter()
4850 .map(|s| s.payload)
4851 .collect()
4852 } else {
4853 Vec::new()
4854 };
4855 slot.writer.add_reader_proxy(proxy);
4856 // Path-MTU-aware fragmentation: if ALL matched
4857 // readers run on the same host, traffic goes via
4858 // loopback (MTU 65536) — then one datagram per sample
4859 // instead of N 1344-B fragments (halves the 8-kB roundtrip
4860 // latency). As soon as a reader is remote, it stays
4861 // Ethernet-safe at DEFAULT_FRAGMENT_SIZE, so no
4862 // oversized datagram gets IP-fragmented on the 1500-byte
4863 // path.
4864 let all_same_host = slot
4865 .writer
4866 .reader_proxies()
4867 .iter()
4868 .all(|p| self.guid_prefix.is_same_host(p.remote_reader_guid.prefix));
4869 if all_same_host {
4870 slot.writer
4871 .set_fragmentation(LOOPBACK_FRAGMENT_SIZE, LOOPBACK_MTU);
4872 } else {
4873 slot.writer
4874 .set_fragmentation(DEFAULT_FRAGMENT_SIZE, DEFAULT_MTU);
4875 }
4876 // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): if the
4877 // remote reader runs on the same host (matching
4878 // GuidPrefix host-id, wave 4a), register the pair in the
4879 // SameHostTracker. Wave 4b.3 (feature `same-host-shm`):
4880 // additionally try to set up a PosixShmTransport owner
4881 // segment — on success `mark_bound(Owner)`,
4882 // otherwise `mark_failed` and UDP fallback.
4883 if self.guid_prefix.is_same_host(sub.key.prefix) {
4884 let local_writer_guid =
4885 zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
4886 self.same_host.register_pending(local_writer_guid, sub.key);
4887 #[cfg(feature = "same-host-shm")]
4888 {
4889 match crate::same_host_shm::open_owner_segment(
4890 self.guid_prefix,
4891 local_writer_guid,
4892 sub.key,
4893 ) {
4894 Ok(t) => self.same_host.mark_bound(
4895 local_writer_guid,
4896 sub.key,
4897 t,
4898 crate::same_host::Role::Owner,
4899 ),
4900 Err(reason) => {
4901 self.same_host
4902 .mark_failed(local_writer_guid, sub.key, reason)
4903 }
4904 }
4905 }
4906 }
4907 // Inject the backend replay into the HistoryCache (within
4908 // the slot lock). Important: with `KeepLast(N)` and a small N
4909 // the cache would immediately evict every replay sample
4910 // again — the subsequent writer tick then sees
4911 // SN=4,5 as "not in cache" and sends GAPs to the
4912 // reader, which marks our replay samples as irrelevant.
4913 // Solution: temporarily expand the cache to `KeepAll` with
4914 // a sufficient cap, for the duration of the
4915 // burst, then restore the user QoS.
4916 // Backend samples are in **raw** format (that is how
4917 // `DataWriter::write` in publisher.rs stores them) — before the
4918 // writer.write we must prepend the USER_PAYLOAD_ENCAP framing,
4919 // so the reader recognizes the stream value spec-conformantly
4920 // (see `validate_user_encap_offset`).
4921 let now_replay = self.start_instant.elapsed();
4922 if !backend_writes.is_empty() {
4923 // Same encap header as in the live-write path
4924 // (offer `first` + extensibility), so replay samples
4925 // declare the same wire encoding.
4926 let replay_encap = {
4927 let offer_first = slot
4928 .data_rep_offer_override
4929 .as_ref()
4930 .and_then(|v| v.first().copied())
4931 .or_else(|| self.config.data_representation_offer.first().copied())
4932 .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
4933 user_payload_encap(offer_first, slot.wire_extensibility)
4934 };
4935 let original_kind = slot.writer.cache().kind();
4936 let original_max = slot.writer.cache().max_samples();
4937 let burst_max = original_max
4938 .saturating_add(backend_writes.len())
4939 .max(backend_writes.len() + 16);
4940 slot.writer.set_cache_kind_and_max(
4941 zerodds_rtps::history_cache::HistoryKind::KeepAll,
4942 burst_max,
4943 );
4944 for raw_payload in &backend_writes {
4945 let mut framed = Vec::with_capacity(replay_encap.len() + raw_payload.len());
4946 framed.extend_from_slice(&replay_encap);
4947 framed.extend_from_slice(raw_payload);
4948 if let Ok(out) = slot.writer.write_with_heartbeat(&framed, now_replay) {
4949 replay_dgs.extend(out);
4950 }
4951 }
4952 slot.writer
4953 .set_cache_kind_and_max(original_kind, original_max);
4954 slot.backend_primed = true;
4955 }
4956 // D.5e Phase-1: wake `wait_for_matched_subscription`-waiters.
4957 self.match_event.1.notify_all();
4958
4959 // Security: derive the per-reader protection level from
4960 // security_info and build the locator lookup map,
4961 // so the writer tick can serialize per target
4962 // individually.
4963 #[cfg(feature = "security")]
4964 {
4965 let peer_key = sub.key.prefix.0;
4966 // Set the per-reader level ONLY for an EXPLICITLY announced
4967 // `PID_ENDPOINT_SECURITY_INFO`. If it is missing (OpenDDS does not
4968 // send it — it relies on the domain governance), NO
4969 // None override: then the governance `data_protection` FLOOR
4970 // applies in `secure_outbound_for_target`. An authenticated peer
4971 // in a data_protection=ENCRYPT domain expects the encrypted
4972 // payload; a None override would leak plaintext (cyclone/
4973 // FastDDS announce security_info → unchanged).
4974 if let Some(info) = sub.security_info.as_ref() {
4975 let level = EndpointProtection::from_info(Some(info)).level;
4976 slot.reader_protection.insert(peer_key, level);
4977 }
4978 for loc in &locators {
4979 slot.locator_to_peer.insert(*loc, peer_key);
4980 }
4981 }
4982 }
4983 }
4984 // Send the backend replay datagrams (Spec §2.2.3.5). The slot mutex
4985 // is released here; the send path mirrors the pattern from
4986 // `write_user_sample` — including the in-process fastpath for
4987 // same-process peers (otherwise UDP loopback loss under load can
4988 // swallow the Transient/Persistent replay samples).
4989 let inproc_peers: Vec<Arc<DcpsRuntime>> = {
4990 let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
4991 all.into_iter()
4992 .filter(|rt| rt.guid_prefix != self.guid_prefix)
4993 .collect()
4994 };
4995 let now_send = self.start_instant.elapsed();
4996 for dg in &replay_dgs {
4997 for t in dg.targets.iter() {
4998 if is_routable_user_locator(t) {
4999 let _ = self.user_unicast.send(t, &dg.bytes);
5000 }
5001 }
5002 for peer in &inproc_peers {
5003 handle_user_datagram(peer, &dg.bytes, now_send);
5004 }
5005 }
5006 // Emit the match event outside the slot mutex.
5007 self.config.observability.record(
5008 &zerodds_foundation::observability::Event::new(
5009 zerodds_foundation::observability::Level::Info,
5010 zerodds_foundation::observability::Component::Discovery,
5011 "writer.matched_remote_reader",
5012 )
5013 .with_attr("writer_eid", alloc::format!("{writer_eid:?}")),
5014 );
5015 }
5016
5017 fn wire_reader_to_remote_writer(
5018 &self,
5019 reader_eid: EntityId,
5020 pubd: &zerodds_rtps::publication_data::PublicationBuiltinTopicData,
5021 ) {
5022 // §2.2.2.2.1.17: an ignored publication must not be MATCHED, not merely
5023 // hidden from the DCPSPublication builtin reader. The Durability-Service
5024 // relies on this to avoid ingesting its own replay writer (echo loop).
5025 if let Some(filter) = self.ignore_filter_snapshot() {
5026 let pub_h = crate::instance_handle::InstanceHandle::from_guid(pubd.key);
5027 let part_h = crate::instance_handle::InstanceHandle::from_guid(pubd.participant_key);
5028 if filter.is_publication_ignored(pub_h) || filter.is_participant_ignored(part_h) {
5029 return;
5030 }
5031 }
5032 let locators =
5033 endpoint_or_default_locators(&pubd.unicast_locators, pubd.key.prefix, &self.discovered);
5034 if locators.is_empty() {
5035 return;
5036 }
5037 if let Some(slot_arc) = self.reader_slot(reader_eid) {
5038 if let Ok(mut slot) = slot_arc.lock() {
5039 let slot = &mut *slot;
5040 // Idempotency gate (symmetric to
5041 // `wire_writer_to_remote_reader`): if a WriterProxy already
5042 // exists for this remote writer, the
5043 // match has already run. A re-wire via UDP SEDP after
5044 // an in-process pull would REPLACE via `add_writer_proxy` —
5045 // resetting `delivered_up_to`/`received` and
5046 // losing already-buffered/delivered samples.
5047 if slot
5048 .reader
5049 .writer_proxies()
5050 .iter()
5051 .any(|s| s.proxy.remote_writer_guid == pubd.key)
5052 {
5053 return;
5054 }
5055 // Per-policy bump for requested_incompatible_qos.
5056 use crate::psm_constants::qos_policy_id as qid;
5057 use crate::status::bump_policy_count;
5058 // C2 "loud instead of silent" (symmetric to the writer side):
5059 // an incompatible QoS match is logged loudly immediately.
5060 let obs = self.config.observability.clone();
5061 let topic_for_log = slot.topic_name.clone();
5062 let remote_for_log = alloc::format!("{:?}", pubd.key);
5063 let bump = |slot: &mut UserReaderSlot, pid: u32| {
5064 slot.requested_incompatible_qos.total_count = slot
5065 .requested_incompatible_qos
5066 .total_count
5067 .saturating_add(1);
5068 slot.requested_incompatible_qos.last_policy_id = pid;
5069 bump_policy_count(&mut slot.requested_incompatible_qos.policies, pid);
5070 obs.record(
5071 &zerodds_foundation::observability::Event::new(
5072 zerodds_foundation::observability::Level::Warn,
5073 zerodds_foundation::observability::Component::Dcps,
5074 "qos.incompatible.requested",
5075 )
5076 .with_attr("topic", topic_for_log.as_str())
5077 .with_attr("remote_writer", remote_for_log.as_str())
5078 .with_attr("policy", qos_policy_id_name(pid)),
5079 );
5080 };
5081
5082 // See wire_writer... — symmetric, the writer is now remote.
5083 if (pubd.durability as u8) < (slot.durability as u8) {
5084 bump(slot, qid::DURABILITY);
5085 return;
5086 }
5087 if !deadline_compat(
5088 qos_duration_to_nanos(pubd.deadline.period),
5089 slot.deadline_nanos,
5090 ) {
5091 bump(slot, qid::DEADLINE);
5092 return;
5093 }
5094 if (pubd.liveliness.kind as u8) < (slot.liveliness_kind as u8) {
5095 bump(slot, qid::LIVELINESS);
5096 return;
5097 }
5098 if !deadline_compat(
5099 qos_duration_to_nanos(pubd.liveliness.lease_duration),
5100 slot.liveliness_lease_nanos,
5101 ) {
5102 bump(slot, qid::LIVELINESS);
5103 return;
5104 }
5105 if pubd.ownership != slot.ownership {
5106 bump(slot, qid::OWNERSHIP);
5107 return;
5108 }
5109 if !partitions_overlap(&pubd.partition, &slot.partition) {
5110 bump(slot, qid::PARTITION);
5111 return;
5112 }
5113
5114 // F-TYPES-3 XTypes-1.3 §7.6.3.7 TypeConsistencyEnforcement.
5115 // If both sides carry a TypeIdentifier (≠ None),
5116 // we check compatibility via the TypeMatcher. Otherwise
5117 // the match falls back to a pure type_name comparison (default path).
5118 if slot.type_identifier != zerodds_types::TypeIdentifier::None
5119 && pubd.type_identifier != zerodds_types::TypeIdentifier::None
5120 {
5121 let registry = zerodds_types::resolve::TypeRegistry::new();
5122 let matcher =
5123 zerodds_types::type_matcher::TypeMatcher::new(&slot.type_consistency);
5124 if !matcher
5125 .match_types(&pubd.type_identifier, &slot.type_identifier, ®istry)
5126 .is_match()
5127 {
5128 bump(slot, qid::TYPE_CONSISTENCY_ENFORCEMENT);
5129 return;
5130 }
5131 }
5132
5133 slot.reader
5134 .add_writer_proxy(zerodds_rtps::writer_proxy::WriterProxy::new(
5135 pubd.key,
5136 locators,
5137 Vec::new(),
5138 true,
5139 ));
5140 // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): reader
5141 // side of the same-host match. If the remote writer runs on
5142 // the same host, register the pair AND
5143 // attach synchronously to the SHM segment.
5144 //
5145 // Idempotent: thanks to the `PosixShmTransport::open` refactor
5146 // (transport-shm bug fix 2026-05-19) it does not matter whether the
5147 // writer hook (open_owner) or the reader hook
5148 // (open_consumer) runs first — whoever comes first
5149 // creates the segment, whoever later attaches. Real-life
5150 // DDS has no guaranteed SEDP match order.
5151 if self.guid_prefix.is_same_host(pubd.key.prefix) {
5152 let local_reader_guid =
5153 zerodds_rtps::wire_types::Guid::new(self.guid_prefix, reader_eid);
5154 self.same_host.register_pending(pubd.key, local_reader_guid);
5155 #[cfg(feature = "same-host-shm")]
5156 {
5157 match crate::same_host_shm::open_consumer_segment(
5158 self.guid_prefix,
5159 pubd.key,
5160 local_reader_guid,
5161 ) {
5162 Ok(t) => self.same_host.mark_bound(
5163 pubd.key,
5164 local_reader_guid,
5165 t,
5166 crate::same_host::Role::Consumer,
5167 ),
5168 Err(reason) => {
5169 self.same_host
5170 .mark_failed(pubd.key, local_reader_guid, reason)
5171 }
5172 }
5173 }
5174 }
5175 // D.5e Phase-1: wake `wait_for_matched_publication`-waiters.
5176 self.match_event.1.notify_all();
5177
5178 // §2.2.3.23 exclusive-ownership resolver cache:
5179 // remember the writer `ownership_strength` from discovery, so
5180 // `delivered_to_user_sample` can pack the value into every
5181 // sample.
5182 slot.writer_strengths
5183 .insert(pubd.key.to_bytes(), pubd.ownership_strength);
5184 }
5185 }
5186 }
5187
5188 /// Writes a sample to a registered user writer and
5189 /// sends the generated datagrams.
5190 ///
5191 /// The payload is prefixed with the RTPS serialized-payload header
5192 /// (encapsulation scheme) before it goes into the DATA
5193 /// submessage. OMG RTPS 2.5 §9.4.2.13 requires exactly these
5194 /// 4 bytes at the start of every serialized user payload —
5195 /// see [`USER_PAYLOAD_ENCAP`] (`CDR_LE` / XCDR1).
5196 /// Without this header Cyclone/Fast-DDS readers refuse to
5197 /// deliver the sample (they parse the first 4 bytes as
5198 /// encapsulation kind + options and drop unknown-scheme).
5199 ///
5200 /// # Errors
5201 /// - `BadParameter` if the EntityId has no registered writer.
5202 /// - `WireError` on an encoding error.
5203 pub fn write_user_sample(&self, eid: EntityId, payload: Vec<u8>) -> Result<()> {
5204 // Vec-ownership API. The spec contract is unchanged. We delegate to
5205 // the borrowed variant; this saves a heap-allocation hop when
5206 // the caller already has a `&[u8]` (e.g. the C-FFI loan API).
5207 self.write_user_sample_borrowed(eid, &payload)
5208 }
5209
5210 /// Sets the per-writer data-representation override for a user writer. The
5211 /// next `write_user_sample*` derives its encapsulation header from this
5212 /// override's first element instead of the runtime default — so a
5213 /// representation-faithful re-publisher (e.g. the durability service
5214 /// replaying foreign-vendor XCDR1 bytes) can declare the encap that matches
5215 /// the body it holds. `None` clears the override (back to the runtime
5216 /// default). Idempotent + cheap; safe to call before every write.
5217 ///
5218 /// # Errors
5219 /// `BadParameter` for an unknown writer entity id; `PreconditionNotMet` on a
5220 /// poisoned slot lock.
5221 pub fn set_user_writer_data_rep_override(
5222 &self,
5223 eid: EntityId,
5224 offer: Option<Vec<i16>>,
5225 ) -> Result<()> {
5226 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5227 what: "unknown writer entity id",
5228 })?;
5229 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5230 reason: "user_writer slot poisoned",
5231 })?;
5232 slot.data_rep_offer_override = offer;
5233 Ok(())
5234 }
5235
5236 /// Writes a user sample from a borrowed byte slice.
5237 /// **Zero-copy path** for the loan API and SHM backend: avoids
5238 /// the Vec materialization when the caller holds a slot/stack buffer.
5239 ///
5240 /// Identical semantics to `write_user_sample`; it just takes no
5241 /// ownership of the buffer.
5242 ///
5243 /// # Errors
5244 /// As `write_user_sample`.
5245 pub fn write_user_sample_borrowed(&self, eid: EntityId, payload: &[u8]) -> Result<()> {
5246 let _phase_guard = if phase_timing_enabled() {
5247 Some(PhaseTimer {
5248 start: std::time::Instant::now(),
5249 ns_acc: &PHASE_WRITE_USER_NS,
5250 calls_acc: &PHASE_WRITE_USER_CALLS,
5251 })
5252 } else {
5253 None
5254 };
5255 let pt_on = phase_timing_enabled();
5256 let pt_t0 = if pt_on {
5257 Some(std::time::Instant::now())
5258 } else {
5259 None
5260 };
5261 // Hot path: for small samples (<= 1.5 kB payload)
5262 // the encap framing is copied into a stack PoolBuffer —
5263 // zero heap touch in the framing step. Large samples fall
5264 // back to Vec.
5265 let now = self.start_instant.elapsed();
5266 let total = USER_PAYLOAD_ENCAP.len() + payload.len();
5267 let pt_t2_out: Option<std::time::Instant>;
5268 let out_datagrams = {
5269 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5270 what: "unknown writer entity id",
5271 })?;
5272 let pt_t1 = if pt_on {
5273 Some(std::time::Instant::now())
5274 } else {
5275 None
5276 };
5277 if let (Some(t0), Some(t1)) = (pt_t0, pt_t1) {
5278 PHASE_WRITE_SUB_NS[0].fetch_add(
5279 (t1 - t0).as_nanos() as u64,
5280 core::sync::atomic::Ordering::Relaxed,
5281 );
5282 }
5283 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5284 reason: "user_writer slot poisoned",
5285 })?;
5286 let pt_t2 = if pt_on {
5287 Some(std::time::Instant::now())
5288 } else {
5289 None
5290 };
5291 pt_t2_out = pt_t2;
5292 if let (Some(t1), Some(t2)) = (pt_t1, pt_t2) {
5293 PHASE_WRITE_SUB_NS[1].fetch_add(
5294 (t2 - t1).as_nanos() as u64,
5295 core::sync::atomic::Ordering::Relaxed,
5296 );
5297 }
5298 // Deadline timer: remember the last write for offered_deadline_missed.
5299 slot.last_write = Some(now);
5300 // Encap header from the effective offer `first` (per-writer
5301 // override else runtime default) + type extensibility. The app
5302 // encoder serializes exactly this wire format; the header must
5303 // declare it honestly (otherwise an XCDR2-only vendor
5304 // reader misparses). See `user_payload_encap`.
5305 let encap = {
5306 let offer_first = slot
5307 .data_rep_offer_override
5308 .as_ref()
5309 .and_then(|v| v.first().copied())
5310 .or_else(|| self.config.data_representation_offer.first().copied())
5311 .unwrap_or(zerodds_rtps::publication_data::data_representation::XCDR);
5312 user_payload_encap(offer_first, slot.wire_extensibility)
5313 };
5314 // Spec §2.2.3.5 backend filling happens in
5315 // `DataWriter::write` (publisher.rs) with the **raw** payload —
5316 // here only the HistoryCache filling + wire send.
5317 let dgs = if total <= SMALL_FRAME_CAP {
5318 write_user_sample_pooled(&mut slot.writer, payload, now, &encap)?
5319 } else {
5320 let mut framed = Vec::with_capacity(total);
5321 framed.extend_from_slice(&encap);
5322 framed.extend_from_slice(payload);
5323 // See write_user_sample_pooled: HB rate-limited via the
5324 // tick loop instead of per-write.
5325 let _ = now;
5326 slot.writer
5327 .write(&framed)
5328 .map_err(|_| DdsError::WireError {
5329 message: String::from("user writer encode"),
5330 })?
5331 };
5332 // Lifespan: remember the insert time of the just-written SN.
5333 if slot.lifespan_nanos != 0 {
5334 if let Some(sn) = slot.writer.cache().max_sn() {
5335 slot.sample_insert_times.push_back((sn, now));
5336 }
5337 }
5338 dgs
5339 };
5340 let pt_t3 = if pt_on {
5341 Some(std::time::Instant::now())
5342 } else {
5343 None
5344 };
5345 if let (Some(t2), Some(t3)) = (pt_t2_out, pt_t3) {
5346 PHASE_WRITE_SUB_NS[2].fetch_add(
5347 (t3 - t2).as_nanos() as u64,
5348 core::sync::atomic::Ordering::Relaxed,
5349 );
5350 }
5351 // Opt-4 (Spec `zerodds-zero-copy-1.0` §9): precompute the skip set
5352 // of UDP locators occupied by a bound same-host reader.
5353 // Readers on these locators get the sample via
5354 // SHM (`same_host_send_pass` below); a UDP send would be a duplicate.
5355 #[cfg(feature = "same-host-shm")]
5356 let same_host_skip_locators: Vec<Locator> = self.same_host_udp_skip_set(eid);
5357 // In-process fastpath (same-process+domain peers): snapshot the
5358 // peer runtimes ONCE per write, then feed each datagram directly into
5359 // their recv path — no UDP loopback, no reliable
5360 // recovery race. The receiver deduplicates by SequenceNumber,
5361 // a copy arriving additionally via UDP later is a
5362 // no-op. The wire path stays untouched for cross-process.
5363 //
5364 // Hot-path fast path: lock-free registry hint. In the typical
5365 // cross-process bench (ping in process A, pong in process B)
5366 // A's registry has only A — the `peers()` lock+Vec alloc would be
5367 // pure overhead per write. Skip when count ≤ 1.
5368 let inproc_peers: Vec<Arc<DcpsRuntime>> = if crate::inproc::registry_count_hint() <= 1 {
5369 Vec::new()
5370 } else {
5371 let all = crate::inproc::peers(self.domain_id, self.config.spdp_multicast_group);
5372 all.into_iter()
5373 .filter(|rt| rt.guid_prefix != self.guid_prefix)
5374 .collect()
5375 };
5376 for dg in out_datagrams {
5377 // FU2 S3: UDP per target with per-reader data_protection
5378 // (`secure_outbound_for_target` — heterogeneously correct: legacy readers
5379 // get plaintext, secure readers SRTPS; the governance
5380 // data_protection fallback applies for readers without explicit
5381 // SEDP security_info).
5382 for t in dg.targets.iter() {
5383 if is_routable_user_locator(t) {
5384 #[cfg(feature = "same-host-shm")]
5385 if same_host_skip_locators.iter().any(|s| s == t) {
5386 continue;
5387 }
5388 if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
5389 #[allow(clippy::print_stderr)]
5390 if let Err(e) = self.user_unicast.send(t, &secured) {
5391 if std::env::var("ZERODDS_TRACE_SEND_ERR")
5392 .map(|s| s == "1")
5393 .unwrap_or(false)
5394 {
5395 eprintln!("[TRACE] user_unicast.send({t:?}) failed: {e:?}");
5396 }
5397 }
5398 }
5399 }
5400 }
5401 // SHM + in-process fastpath: `secure_user_outbound` (uniform
5402 // governance data_protection level). The inproc peer runs through
5403 // its secured inbound path (decrypt or drop),
5404 // symmetric to the UDP recv — otherwise a non-
5405 // authenticated same-process peer could see encrypted data
5406 // unencrypted.
5407 if let Some(secured) = secure_user_outbound(self, &dg.bytes) {
5408 // Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6):
5409 // parallel send via SHM to all bound-owner entries
5410 // for this writer. Opt-4 above filters their UDP
5411 // locators out beforehand, so nothing is sent twice.
5412 #[cfg(feature = "same-host-shm")]
5413 self.same_host_send_pass(eid, &secured);
5414 for peer in &inproc_peers {
5415 #[cfg(feature = "security")]
5416 {
5417 if let Some(clear) =
5418 secure_inbound_bytes(peer, &secured, &DEFAULT_INBOUND_IFACE)
5419 {
5420 handle_user_datagram(peer, &clear, now);
5421 }
5422 }
5423 #[cfg(not(feature = "security"))]
5424 handle_user_datagram(peer, &secured, now);
5425 }
5426 }
5427 }
5428 let pt_t4 = if pt_on {
5429 Some(std::time::Instant::now())
5430 } else {
5431 None
5432 };
5433 if let (Some(t3), Some(t4)) = (pt_t3, pt_t4) {
5434 PHASE_WRITE_SUB_NS[3].fetch_add(
5435 (t4 - t3).as_nanos() as u64,
5436 core::sync::atomic::Ordering::Relaxed,
5437 );
5438 }
5439 // Same-runtime writer→reader loopback: in parallel to the wire path
5440 // push directly into the `sample_tx` of all local readers on the same
5441 // topic+type. Bridge-daemon use case (writer+reader
5442 // in the same DcpsRuntime); without this hook intra-process
5443 // loopback would be completely dead, because `inproc_announce_*` skips self
5444 // and UDP multicast loopback is not guaranteed. Strength from
5445 // the writer slot.
5446 let writer_strength = self
5447 .writer_slot(eid)
5448 .and_then(|arc| arc.lock().ok().map(|s| s.ownership_strength))
5449 .unwrap_or(0);
5450 self.intra_runtime_dispatch_alive(eid, payload, writer_strength);
5451 // Embargo inspect tap at the DCPS layer (path-separated from the
5452 // production path). Only compiled when the `inspect` feature is
5453 // on. The topic name is fetched via a separate lookup, outside
5454 // the lock region so hooks do not run under the lock.
5455 #[cfg(feature = "inspect")]
5456 {
5457 self.dispatch_inspect_dcps_tap(eid, payload);
5458 }
5459 // D.5e Phase 3 — a freshly written sample makes a HEARTBEAT due: wake the
5460 // scheduler tick so it goes out immediately (no 5 ms tail), speeding the
5461 // reliable HB→ACKNACK handshake.
5462 self.raise_tick_wake();
5463 Ok(())
5464 }
5465
5466 /// Wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) helper:
5467 /// sends `bytes` to all bound-owner entries of the [`SameHostTracker`]
5468 /// for this local writer (owner role).
5469 ///
5470 /// Called by the [`Self::write_user_sample`] hot path after the UDP send.
5471 /// Same-host readers thereby receive the sample frame
5472 /// via SHM **in addition** to the UDP path — the reader HistoryCache
5473 /// deduplicates by SequenceNumber.
5474 #[cfg(feature = "same-host-shm")]
5475 /// Opt-4 (Spec `zerodds-zero-copy-1.0` §9): locator skip set for
5476 /// the UDP send path. Returns all UDP default-unicast locators
5477 /// of the readers that have a bound same-host SHM pair with this
5478 /// writer — the hot-path caller filters these targets out of
5479 /// `dg.targets`, so the same readers are not served twice
5480 /// (UDP + SHM).
5481 #[cfg(feature = "same-host-shm")]
5482 fn same_host_udp_skip_set(&self, writer_eid: EntityId) -> Vec<Locator> {
5483 use crate::same_host::{Role, SameHostState};
5484 let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
5485 let mut skip: Vec<Locator> = Vec::new();
5486 let snapshot = self.same_host.snapshot();
5487 let discovered = self.discovered.clone();
5488 for (w, reader, state) in snapshot {
5489 if w != writer_guid {
5490 continue;
5491 }
5492 if !matches!(
5493 state,
5494 SameHostState::Bound {
5495 role: Role::Owner,
5496 ..
5497 }
5498 ) {
5499 continue;
5500 }
5501 // Reader prefix → default_unicast_locator from discovery.
5502 if let Ok(cache) = discovered.lock() {
5503 if let Some(p) = cache.get(&reader.prefix) {
5504 if let Some(loc) = p.data.default_unicast_locator {
5505 skip.push(loc);
5506 }
5507 }
5508 }
5509 }
5510 skip
5511 }
5512
5513 #[cfg(feature = "same-host-shm")]
5514 fn same_host_send_pass(&self, writer_eid: EntityId, bytes: &[u8]) {
5515 use crate::same_host::{Role, SameHostState};
5516 use zerodds_transport::Transport;
5517 use zerodds_transport_shm::PosixShmTransport;
5518
5519 let writer_guid = zerodds_rtps::wire_types::Guid::new(self.guid_prefix, writer_eid);
5520 let snapshot = self.same_host.snapshot();
5521 let total = snapshot.len();
5522 let mut matched = 0u32;
5523 let mut owners = 0u32;
5524 let mut sent = 0u32;
5525 for (w, _reader, state) in snapshot {
5526 if w != writer_guid {
5527 continue;
5528 }
5529 matched += 1;
5530 let SameHostState::Bound { transport, role } = state else {
5531 continue;
5532 };
5533 if !matches!(role, Role::Owner) {
5534 continue;
5535 }
5536 owners += 1;
5537 let Ok(t) = transport.downcast::<PosixShmTransport>() else {
5538 continue;
5539 };
5540 // ShmTransport is 1:1: send() validates `dest ==
5541 // peer_locator`. Owner.peer_locator points to the
5542 // consumer endpoint → that is our target.
5543 let target = t.peer_locator();
5544 if t.send(&target, bytes).is_ok() {
5545 sent += 1;
5546 }
5547 }
5548 let _ = (total, matched, owners, sent); // diag counter removed after the Bug-3 fix
5549 }
5550
5551 /// Inspect-endpoint tap dispatch for DCPS publish.
5552 /// Reads the topic name separately from the WriterSlot and passes
5553 /// a frame to the zerodds-inspect-endpoint tap registry.
5554 /// **Not** the production hot path: only when the `inspect` feature is on.
5555 #[cfg(feature = "inspect")]
5556 fn dispatch_inspect_dcps_tap(&self, eid: EntityId, payload: &[u8]) {
5557 let Some(slot_arc) = self.writer_slot(eid) else {
5558 return;
5559 };
5560 let topic = match slot_arc.lock() {
5561 Ok(slot) => slot.topic_name.clone(),
5562 Err(_) => return,
5563 };
5564 let ts_ns = std::time::SystemTime::now()
5565 .duration_since(std::time::UNIX_EPOCH)
5566 .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
5567 .unwrap_or(0);
5568 let mut corr: u64 = 0;
5569 for (i, byte) in eid.entity_key.iter().enumerate() {
5570 corr |= u64::from(*byte) << (i * 8);
5571 }
5572 corr |= u64::from(eid.entity_kind as u8) << 24;
5573 let frame = zerodds_inspect_endpoint::Frame::dcps(topic, ts_ns, corr, payload.to_vec());
5574 zerodds_inspect_endpoint::tap::dispatch(&frame);
5575 }
5576
5577 /// Sends a lifecycle marker (`dispose`/`unregister_instance`) to
5578 /// all matched readers. Spec §2.2.2.4.2.10/.7 + §9.6.3.9 PID_STATUS_INFO.
5579 /// `status_bits` is the OR combination of
5580 /// `zerodds_rtps::inline_qos::status_info::DISPOSED` and/or `UNREGISTERED`.
5581 ///
5582 /// # Errors
5583 /// - `BadParameter` if the EntityId has no registered writer.
5584 /// - `WireError` on an encode error.
5585 pub fn write_user_lifecycle(
5586 &self,
5587 eid: EntityId,
5588 key_hash: [u8; 16],
5589 status_bits: u32,
5590 ) -> Result<()> {
5591 let out_datagrams = {
5592 let slot_arc = self.writer_slot(eid).ok_or(DdsError::BadParameter {
5593 what: "unknown writer entity id",
5594 })?;
5595 let mut slot = slot_arc.lock().map_err(|_| DdsError::PreconditionNotMet {
5596 reason: "user_writer slot poisoned",
5597 })?;
5598 slot.writer
5599 .write_lifecycle(key_hash, status_bits)
5600 .map_err(|_| DdsError::WireError {
5601 message: String::from("user writer lifecycle encode"),
5602 })?
5603 };
5604 for dg in out_datagrams {
5605 // FU2 S3: lifecycle DATA (dispose/unregister) per-target
5606 // data_protection-aware (heterogeneously correct like the immediate send).
5607 for t in dg.targets.iter() {
5608 if is_routable_user_locator(t) {
5609 if let Some(secured) = secure_outbound_for_target(self, eid, &dg.bytes, t) {
5610 let _ = self.user_unicast.send(t, &secured);
5611 }
5612 }
5613 }
5614 }
5615 Ok(())
5616 }
5617
5618 /// Generates a 3-byte entity key for new user endpoints.
5619 fn next_entity_key(&self) -> [u8; 3] {
5620 let n = self.entity_counter.fetch_add(1, Ordering::Relaxed);
5621 [(n >> 16) as u8, (n >> 8) as u8, n as u8]
5622 }
5623
5624 /// Snapshot of all currently known remote publications (topic
5625 /// name + type name + writer GUID).
5626 #[must_use]
5627 pub fn discovered_publications_count(&self) -> usize {
5628 self.sedp
5629 .lock()
5630 .map(|s| s.cache().publications_len())
5631 .unwrap_or(0)
5632 }
5633
5634 /// Snapshot of every publication on this domain as `(topic_name,
5635 /// type_name)` — raw DDS topic/type strings — for graph introspection
5636 /// (`rmw_get_topic_names_and_types`, `rmw_count_publishers`). Includes BOTH
5637 /// this participant's LOCAL user writers AND the remote publications from
5638 /// SEDP, so a node sees its own topics as well as its peers'.
5639 #[must_use]
5640 pub fn discovered_publication_topics(&self) -> Vec<(String, String)> {
5641 let mut out: Vec<(String, String)> = Vec::new();
5642 if let Ok(map) = self.user_writers.read() {
5643 for slot in map.values() {
5644 if let Ok(s) = slot.lock() {
5645 out.push((s.topic_name.clone(), s.type_name.clone()));
5646 }
5647 }
5648 }
5649 if let Ok(s) = self.sedp.lock() {
5650 out.extend(
5651 s.cache()
5652 .publications()
5653 .map(|p| (p.data.topic_name.clone(), p.data.type_name.clone())),
5654 );
5655 }
5656 out
5657 }
5658
5659 /// Snapshot of every subscription on this domain as `(topic_name,
5660 /// type_name)` (local user readers + remote SEDP). Counterpart to
5661 /// [`Self::discovered_publication_topics`].
5662 #[must_use]
5663 pub fn discovered_subscription_topics(&self) -> Vec<(String, String)> {
5664 let mut out: Vec<(String, String)> = Vec::new();
5665 if let Ok(map) = self.user_readers.read() {
5666 for slot in map.values() {
5667 if let Ok(s) = slot.lock() {
5668 out.push((s.topic_name.clone(), s.type_name.clone()));
5669 }
5670 }
5671 }
5672 if let Ok(s) = self.sedp.lock() {
5673 out.extend(
5674 s.cache()
5675 .subscriptions()
5676 .map(|s| (s.data.topic_name.clone(), s.data.type_name.clone())),
5677 );
5678 }
5679 out
5680 }
5681
5682 /// Snapshot of all currently known remote subscriptions.
5683 #[must_use]
5684 pub fn discovered_subscriptions_count(&self) -> usize {
5685 self.sedp
5686 .lock()
5687 .map(|s| s.cache().subscriptions_len())
5688 .unwrap_or(0)
5689 }
5690
5691 /// Per-endpoint snapshot of every publication on this domain (local user
5692 /// writers + remote SEDP), for ROS 2 `rmw_get_publishers_info_by_topic`.
5693 #[must_use]
5694 pub fn discovered_publication_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
5695 let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
5696 let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
5697 if let Ok(map) = self.user_writers.read() {
5698 for slot in map.values() {
5699 if let Ok(s) = slot.lock() {
5700 out.push(DiscoveredEndpointInfo {
5701 topic_name: s.topic_name.clone(),
5702 type_name: s.type_name.clone(),
5703 endpoint_guid: guid_to_16(s.writer.guid()),
5704 reliable: s.reliable,
5705 transient_local: !matches!(
5706 s.durability,
5707 zerodds_qos::DurabilityKind::Volatile
5708 ),
5709 deadline_seconds: secs(s.deadline_nanos),
5710 lifespan_seconds: secs(s.lifespan_nanos),
5711 liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
5712 });
5713 }
5714 }
5715 }
5716 if let Ok(s) = self.sedp.lock() {
5717 for p in s.cache().publications() {
5718 out.push(DiscoveredEndpointInfo {
5719 topic_name: p.data.topic_name.clone(),
5720 type_name: p.data.type_name.clone(),
5721 endpoint_guid: guid_to_16(p.data.key),
5722 reliable: matches!(
5723 p.data.reliability.kind,
5724 zerodds_qos::ReliabilityKind::Reliable
5725 ),
5726 transient_local: !matches!(
5727 p.data.durability,
5728 zerodds_qos::DurabilityKind::Volatile
5729 ),
5730 deadline_seconds: p.data.deadline.period.seconds,
5731 lifespan_seconds: p.data.lifespan.duration.seconds,
5732 liveliness_lease_seconds: p.data.liveliness.lease_duration.seconds,
5733 });
5734 }
5735 }
5736 out
5737 }
5738
5739 /// Counterpart to [`Self::discovered_publication_endpoints`] for
5740 /// subscriptions (`rmw_get_subscriptions_info_by_topic`).
5741 #[must_use]
5742 pub fn discovered_subscription_endpoints(&self) -> Vec<DiscoveredEndpointInfo> {
5743 let secs = |nanos: u64| i32::try_from(nanos / 1_000_000_000).unwrap_or(i32::MAX);
5744 let mut out: Vec<DiscoveredEndpointInfo> = Vec::new();
5745 if let Ok(map) = self.user_readers.read() {
5746 for slot in map.values() {
5747 if let Ok(s) = slot.lock() {
5748 out.push(DiscoveredEndpointInfo {
5749 topic_name: s.topic_name.clone(),
5750 type_name: s.type_name.clone(),
5751 endpoint_guid: guid_to_16(s.reader.guid()),
5752 // Reader requested-reliability is not retained in the
5753 // slot; RELIABLE is the rmw default (best-effort field).
5754 reliable: true,
5755 transient_local: !matches!(
5756 s.durability,
5757 zerodds_qos::DurabilityKind::Volatile
5758 ),
5759 deadline_seconds: secs(s.deadline_nanos),
5760 lifespan_seconds: 0,
5761 liveliness_lease_seconds: secs(s.liveliness_lease_nanos),
5762 });
5763 }
5764 }
5765 }
5766 if let Ok(s) = self.sedp.lock() {
5767 for sub in s.cache().subscriptions() {
5768 out.push(DiscoveredEndpointInfo {
5769 topic_name: sub.data.topic_name.clone(),
5770 type_name: sub.data.type_name.clone(),
5771 endpoint_guid: guid_to_16(sub.data.key),
5772 reliable: matches!(
5773 sub.data.reliability.kind,
5774 zerodds_qos::ReliabilityKind::Reliable
5775 ),
5776 transient_local: !matches!(
5777 sub.data.durability,
5778 zerodds_qos::DurabilityKind::Volatile
5779 ),
5780 deadline_seconds: sub.data.deadline.period.seconds,
5781 lifespan_seconds: 0,
5782 liveliness_lease_seconds: sub.data.liveliness.lease_duration.seconds,
5783 });
5784 }
5785 }
5786 out
5787 }
5788
5789 /// Number of matched remote readers for a local user writer.
5790 /// Polled by `DataWriter::wait_for_matched_subscription`.
5791 #[must_use]
5792 pub fn user_writer_matched_count(&self, eid: EntityId) -> usize {
5793 // Distinct matched subscriptions = remote/cross-participant reader
5794 // proxies UNION same-participant (intra-runtime) local readers. The
5795 // intra-runtime self-match path delivers samples without adding a wire
5796 // reader-proxy (avoids UDP-to-self double-delivery), so its matches
5797 // would otherwise be invisible to `wait_for_matched_subscription`.
5798 self.user_writer_matched_subscription_handles(eid).len()
5799 }
5800
5801 /// List of `InstanceHandle`s of all matched readers for a local
5802 /// user writer (Spec §2.2.2.4.2.x `get_matched_subscriptions`): remote/
5803 /// cross-participant readers (reader proxies) plus the same-participant
5804 /// readers from the intra-runtime routes, deduplicated by GUID.
5805 #[must_use]
5806 pub fn user_writer_matched_subscription_handles(
5807 &self,
5808 eid: EntityId,
5809 ) -> Vec<crate::instance_handle::InstanceHandle> {
5810 let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
5811 .writer_slot(eid)
5812 .and_then(|arc| {
5813 arc.lock().ok().map(|s| {
5814 s.writer
5815 .reader_proxies()
5816 .iter()
5817 .map(|p| {
5818 crate::instance_handle::InstanceHandle::from_guid(p.remote_reader_guid)
5819 })
5820 .collect::<Vec<_>>()
5821 })
5822 })
5823 .unwrap_or_default();
5824 for h in self.intra_runtime_writer_matched_readers(eid) {
5825 if !handles.contains(&h) {
5826 handles.push(h);
5827 }
5828 }
5829 handles
5830 }
5831
5832 /// Same-participant readers that the local writer `eid` delivers to via
5833 /// an intra-runtime route (as matched-subscription handles).
5834 fn intra_runtime_writer_matched_readers(
5835 &self,
5836 writer_eid: EntityId,
5837 ) -> Vec<crate::instance_handle::InstanceHandle> {
5838 match self.intra_runtime_routes.read() {
5839 Ok(g) => g
5840 .get(&writer_eid)
5841 .map(|readers| {
5842 readers
5843 .iter()
5844 .map(|reid| {
5845 crate::instance_handle::InstanceHandle::from_guid(Guid::new(
5846 self.guid_prefix,
5847 *reid,
5848 ))
5849 })
5850 .collect()
5851 })
5852 .unwrap_or_default(),
5853 Err(_) => Vec::new(),
5854 }
5855 }
5856
5857 /// Same-participant writers that deliver to the local
5858 /// reader `reader_eid` via an intra-runtime route (as matched-publication handles).
5859 fn intra_runtime_reader_matched_writers(
5860 &self,
5861 reader_eid: EntityId,
5862 ) -> Vec<crate::instance_handle::InstanceHandle> {
5863 match self.intra_runtime_routes.read() {
5864 Ok(g) => g
5865 .iter()
5866 .filter(|(_, readers)| readers.contains(&reader_eid))
5867 .map(|(weid, _)| {
5868 crate::instance_handle::InstanceHandle::from_guid(Guid::new(
5869 self.guid_prefix,
5870 *weid,
5871 ))
5872 })
5873 .collect(),
5874 Err(_) => Vec::new(),
5875 }
5876 }
5877
5878 /// List of `InstanceHandle`s of all matched remote writers for a
5879 /// local user reader (Spec §2.2.2.5.x `get_matched_publications`).
5880 #[must_use]
5881 pub fn user_reader_matched_publication_handles(
5882 &self,
5883 eid: EntityId,
5884 ) -> Vec<crate::instance_handle::InstanceHandle> {
5885 let mut handles: Vec<crate::instance_handle::InstanceHandle> = self
5886 .reader_slot(eid)
5887 .and_then(|arc| {
5888 arc.lock().ok().map(|s| {
5889 s.reader
5890 .writer_proxies()
5891 .iter()
5892 .map(|p| {
5893 crate::instance_handle::InstanceHandle::from_guid(
5894 p.proxy.remote_writer_guid,
5895 )
5896 })
5897 .collect::<Vec<_>>()
5898 })
5899 })
5900 .unwrap_or_default();
5901 for h in self.intra_runtime_reader_matched_writers(eid) {
5902 if !handles.contains(&h) {
5903 handles.push(h);
5904 }
5905 }
5906 handles
5907 }
5908
5909 /// Counter for missed offered deadlines on the user writer.
5910 /// Spec OMG DDS 1.4 §2.2.4.2.9 `OFFERED_DEADLINE_MISSED_STATUS`.
5911 #[must_use]
5912 pub fn user_writer_offered_deadline_missed(&self, eid: EntityId) -> u64 {
5913 self.writer_slot(eid)
5914 .and_then(|arc| arc.lock().ok().map(|s| s.offered_deadline_missed_count))
5915 .unwrap_or(0)
5916 }
5917
5918 /// Counter for missed requested deadlines on the user reader.
5919 /// Spec §2.2.4.2.11 `REQUESTED_DEADLINE_MISSED_STATUS`.
5920 #[must_use]
5921 pub fn user_reader_requested_deadline_missed(&self, eid: EntityId) -> u64 {
5922 self.reader_slot(eid)
5923 .and_then(|arc| arc.lock().ok().map(|s| s.requested_deadline_missed_count))
5924 .unwrap_or(0)
5925 }
5926
5927 /// Current liveliness status of a local user reader.
5928 /// Spec §2.2.4.2.14 `LIVELINESS_CHANGED_STATUS`:
5929 /// `(alive, alive_count, not_alive_count)`.
5930 #[must_use]
5931 pub fn user_reader_liveliness_status(&self, eid: EntityId) -> (bool, u64, u64) {
5932 self.reader_slot(eid)
5933 .and_then(|arc| {
5934 arc.lock().ok().map(|s| {
5935 (
5936 s.liveliness_alive,
5937 s.liveliness_alive_count,
5938 s.liveliness_not_alive_count,
5939 )
5940 })
5941 })
5942 .unwrap_or((false, 0, 0))
5943 }
5944
5945 /// LivelinessLost counter on the user writer (Spec §2.2.4.2.10).
5946 /// Incremented by `check_writer_liveliness`.
5947 #[must_use]
5948 pub fn user_writer_liveliness_lost(&self, eid: EntityId) -> u64 {
5949 self.writer_slot(eid)
5950 .and_then(|arc| arc.lock().ok().map(|s| s.liveliness_lost_count))
5951 .unwrap_or(0)
5952 }
5953
5954 /// Snapshot of OfferedIncompatibleQosStatus on the writer.
5955 #[must_use]
5956 pub fn user_writer_offered_incompatible_qos(
5957 &self,
5958 eid: EntityId,
5959 ) -> crate::status::OfferedIncompatibleQosStatus {
5960 self.writer_slot(eid)
5961 .and_then(|arc| arc.lock().ok().map(|s| s.offered_incompatible_qos.clone()))
5962 .unwrap_or_default()
5963 }
5964
5965 /// Snapshot of RequestedIncompatibleQosStatus on the reader.
5966 #[must_use]
5967 pub fn user_reader_requested_incompatible_qos(
5968 &self,
5969 eid: EntityId,
5970 ) -> crate::status::RequestedIncompatibleQosStatus {
5971 self.reader_slot(eid)
5972 .and_then(|arc| {
5973 arc.lock()
5974 .ok()
5975 .map(|s| s.requested_incompatible_qos.clone())
5976 })
5977 .unwrap_or_default()
5978 }
5979
5980 /// Sample-lost counter (reader side). Spec §2.2.4.2.6.2.
5981 #[must_use]
5982 pub fn user_reader_sample_lost(&self, eid: EntityId) -> u64 {
5983 self.reader_slot(eid)
5984 .and_then(|arc| arc.lock().ok().map(|s| s.sample_lost_count))
5985 .unwrap_or(0)
5986 }
5987
5988 /// Monotonically increasing count of alive samples delivered to the
5989 /// user (Spec §2.2.4.2.6.1 `on_data_available` detector). A delta
5990 /// against the last poll snapshot means "new data available".
5991 #[must_use]
5992 pub fn user_reader_samples_delivered(&self, eid: EntityId) -> u64 {
5993 self.reader_slot(eid)
5994 .and_then(|arc| arc.lock().ok().map(|s| s.samples_delivered_count))
5995 .unwrap_or(0)
5996 }
5997
5998 /// Bug-2 diagnosis (2026-05-19): number of submessages dropped
5999 /// because of an unknown writer_id. If this value is incremented
6000 /// after a write, it indicates an SEDP match
6001 /// race (writer_proxy not yet added when DATA is received).
6002 #[must_use]
6003 pub fn user_reader_unknown_src_count(&self, eid: EntityId) -> u64 {
6004 self.reader_slot(eid)
6005 .and_then(|arc| arc.lock().ok().map(|s| s.reader.unknown_src_count()))
6006 .unwrap_or(0)
6007 }
6008
6009 /// Sample-rejected status (reader side). Spec §2.2.4.2.6.3.
6010 #[must_use]
6011 pub fn user_reader_sample_rejected(
6012 &self,
6013 eid: EntityId,
6014 ) -> crate::status::SampleRejectedStatus {
6015 self.reader_slot(eid)
6016 .and_then(|arc| arc.lock().ok().map(|s| s.sample_rejected))
6017 .unwrap_or_default()
6018 }
6019
6020 /// Records a lost sample on the user reader. Called
6021 /// by resource-limit or decode-failure paths — the
6022 /// detector is application-external, because sample-lost depending on the
6023 /// implementation comes from several sources (cache drop, decode
6024 /// fail, sequence-number gap drop).
6025 pub fn record_sample_lost(&self, eid: EntityId, count: u32) {
6026 if count == 0 {
6027 return;
6028 }
6029 if let Some(arc) = self.reader_slot(eid) {
6030 if let Ok(mut slot) = arc.lock() {
6031 slot.sample_lost_count = slot.sample_lost_count.saturating_add(u64::from(count));
6032 }
6033 }
6034 }
6035
6036 /// Records a rejected sample on the user reader.
6037 pub fn record_sample_rejected(
6038 &self,
6039 eid: EntityId,
6040 kind: crate::status::SampleRejectedStatusKind,
6041 instance: crate::instance_handle::InstanceHandle,
6042 ) {
6043 if let Some(arc) = self.reader_slot(eid) {
6044 if let Ok(mut slot) = arc.lock() {
6045 slot.sample_rejected.total_count =
6046 slot.sample_rejected.total_count.saturating_add(1);
6047 slot.sample_rejected.last_reason = kind;
6048 slot.sample_rejected.last_instance_handle = instance;
6049 }
6050 }
6051 }
6052
6053 /// Manual liveliness assert on the user writer. Sets the
6054 /// `last_liveliness_assert` timestamp. For `LivelinessKind::Automatic`
6055 /// `last_write` is also set — the liveliness path
6056 /// otherwise never falls through the `assert` trigger, because every successful
6057 /// `write` already takes over the liveliness tick.
6058 pub fn assert_writer_liveliness_eid(&self, eid: EntityId) {
6059 let now = self.start_instant.elapsed();
6060 if let Some(arc) = self.writer_slot(eid) {
6061 if let Ok(mut slot) = arc.lock() {
6062 slot.last_liveliness_assert = Some(now);
6063 if slot.liveliness_kind == zerodds_qos::LivelinessKind::Automatic {
6064 slot.last_write = Some(now);
6065 }
6066 }
6067 }
6068 }
6069
6070 /// True if all matched readers have acknowledged all samples written
6071 /// so far. Empty cache or no proxies → true.
6072 #[must_use]
6073 pub fn user_writer_all_acknowledged(&self, eid: EntityId) -> bool {
6074 self.writer_slot(eid)
6075 .and_then(|arc| arc.lock().ok().map(|s| s.writer.all_samples_acknowledged()))
6076 .unwrap_or(true)
6077 }
6078
6079 /// Test helper — pushes a synthetic `UserSample::Alive`
6080 /// directly into the `mpsc::Sender` of the given reader, without
6081 /// going through the wire/discovery path. Enables end-to-end tests of
6082 /// downstream consumers (e.g. bridge-daemon pumps) that otherwise
6083 /// become flaky in CI containers due to multicast-loopback limits.
6084 /// **Not** for production code.
6085 ///
6086 /// `writer_guid` and `writer_strength` are set to default values
6087 /// (shared-ownership assumption).
6088 ///
6089 /// Returns `true` if the reader slot exists and the push
6090 /// succeeded, `false` if the EID is unknown or the channel is
6091 /// closed.
6092 #[doc(hidden)]
6093 pub fn test_inject_user_alive(&self, eid: EntityId, payload: Vec<u8>) -> bool {
6094 let Some(arc) = self.reader_slot(eid) else {
6095 return false;
6096 };
6097 let Ok(mut slot) = arc.lock() else {
6098 return false;
6099 };
6100 let sent = slot
6101 .sample_tx
6102 .send(UserSample::Alive {
6103 payload: crate::sample_bytes::SampleBytes::from_vec(payload),
6104 writer_guid: [0u8; 16],
6105 writer_strength: 0,
6106 representation: 0,
6107 })
6108 .is_ok();
6109 if sent {
6110 slot.samples_delivered_count = slot.samples_delivered_count.saturating_add(1);
6111 }
6112 sent
6113 }
6114
6115 /// Test helper — bumps the inconsistent-topic counter as if matching had
6116 /// discovered a remote endpoint with the same `topic_name` but a
6117 /// different `type_name`. Lets listener-FFI tests exercise the
6118 /// `on_inconsistent_topic` poll path without standing up two
6119 /// participants with a real SEDP type mismatch. **Not** for production.
6120 #[doc(hidden)]
6121 pub fn test_bump_inconsistent_topic(&self) {
6122 self.inconsistent_topic_seq.fetch_add(1, Ordering::Relaxed);
6123 }
6124
6125 /// Spec §3.1 zerodds-async-1.0: registers the waker of an
6126 /// async reader in the UserReaderSlot. On `sample_tx.send`
6127 /// the waker is woken. `None` as the argument clears the waker
6128 /// (e.g. after the async reader is dropped).
6129 pub fn register_user_reader_waker(&self, eid: EntityId, waker: Option<core::task::Waker>) {
6130 if let Some(arc) = self.reader_slot(eid) {
6131 if let Ok(slot) = arc.lock() {
6132 if let Ok(mut g) = slot.async_waker.lock() {
6133 *g = waker;
6134 }
6135 }
6136 }
6137 }
6138
6139 /// Register a listener callback for alive-sample
6140 /// arrival on the user reader. `None` clears an
6141 /// existing listener.
6142 ///
6143 /// The listener fires synchronously on the recv thread of
6144 /// `recv_user_data_loop` — see the contract doc on the
6145 /// [`UserReaderListener`] type. Eliminates the user-polling
6146 /// latency (~50-100 µs) compared to `sample_tx.recv()`.
6147 ///
6148 /// Returns `true` if the reader slot exists and the listener
6149 /// was set, `false` if the EID is not a known user reader.
6150 pub fn set_user_reader_listener(
6151 &self,
6152 eid: EntityId,
6153 listener: Option<UserReaderListener>,
6154 ) -> bool {
6155 let Some(arc) = self.reader_slot(eid) else {
6156 return false;
6157 };
6158 let Ok(mut slot) = arc.lock() else {
6159 return false;
6160 };
6161 slot.listener = listener.map(alloc::sync::Arc::new);
6162 true
6163 }
6164
6165 /// Number of matched writers for a local user reader: remote/cross-
6166 /// participant writers (writer proxies) plus same-participant writers from the
6167 /// intra-runtime routes, deduplicated by GUID (symmetric to the writer).
6168 #[must_use]
6169 pub fn user_reader_matched_count(&self, eid: EntityId) -> usize {
6170 self.user_reader_matched_publication_handles(eid).len()
6171 }
6172
6173 /// D.5e Phase-1 — waits until a match event occurs or the timeout
6174 /// is reached. Replaces 20-ms polling in `DataReader::wait_for_matched_*`
6175 /// and `DataWriter::wait_for_matched_*`.
6176 ///
6177 /// The caller checks the match count itself (via `user_*_matched_count`)
6178 /// before and after the wait — this function is only the block mechanics.
6179 /// Returns `false` if the timeout is reached, `true` if a notify came.
6180 #[cfg(feature = "std")]
6181 pub fn wait_match_event(&self, timeout: core::time::Duration) -> bool {
6182 let (lock, cvar) = &*self.match_event;
6183 let Ok(guard) = lock.lock() else { return false };
6184 match cvar.wait_timeout(guard, timeout) {
6185 Ok((_, t)) => !t.timed_out(),
6186 Err(_) => false,
6187 }
6188 }
6189
6190 /// D.5e Phase-1 — waits until an ACK event occurs or a timeout.
6191 /// Replaces 50-ms polling in `DataWriter::wait_for_acknowledgments`.
6192 #[cfg(feature = "std")]
6193 pub fn wait_ack_event(&self, timeout: core::time::Duration) -> bool {
6194 let (lock, cvar) = &*self.ack_event;
6195 let Ok(guard) = lock.lock() else { return false };
6196 match cvar.wait_timeout(guard, timeout) {
6197 Ok((_, t)) => !t.timed_out(),
6198 Err(_) => false,
6199 }
6200 }
6201
6202 /// D.5e Phase-1 — notify helper for the ACK event. Called by the reliable
6203 /// writer path when an ACKNACK advances the acked-base.
6204 #[cfg(feature = "std")]
6205 pub(crate) fn notify_ack_event(&self) {
6206 self.ack_event.1.notify_all();
6207 }
6208
6209 /// ADR-0006 — sets the PID_SHM_LOCATOR bytes for a local
6210 /// user writer in the side map. Called by the DataWriter
6211 /// once `set_flat_backend` has attached a same-host backend (POSIX shm /
6212 /// Iceoryx2). On the next SEDP push the wire encoder
6213 /// injects PID 0x8001 into the `PublicationData`.
6214 pub fn set_shm_locator(&self, eid: EntityId, bytes: Vec<u8>) {
6215 if let Ok(mut g) = self.shm_locators.write() {
6216 g.insert(eid, bytes);
6217 }
6218 }
6219
6220 /// ADR-0006 — reads the PID_SHM_LOCATOR bytes for a local
6221 /// user writer from the side map. Returns `None` if no
6222 /// same-host backend is set.
6223 #[must_use]
6224 pub fn shm_locator(&self, eid: EntityId) -> Option<Vec<u8>> {
6225 self.shm_locators.read().ok()?.get(&eid).cloned()
6226 }
6227
6228 /// ADR-0006 — removes the PID_SHM_LOCATOR entry (e.g. when the
6229 /// user writer is reconfigured without a backend).
6230 pub fn clear_shm_locator(&self, eid: EntityId) {
6231 if let Ok(mut g) = self.shm_locators.write() {
6232 g.remove(&eid);
6233 }
6234 }
6235
6236 /// Stops all worker threads (recv loops + tick loop) and joins
6237 /// them. Idempotent — repeated calls are no-ops.
6238 ///
6239 /// Shutdown delay: up to ~1 s, because the recv threads sit in
6240 /// `recv()` with a 1 s read timeout. After the
6241 /// current recv() call finishes they check the stop flag and
6242 /// terminate.
6243 pub fn shutdown(&self) {
6244 self.stop.store(true, Ordering::Relaxed);
6245 // D.5e Phase 3 — wake the scheduler tick worker so it observes `stop`
6246 // immediately instead of parking up to the idle floor.
6247 if let Ok(guard) = self.tick_wake.lock() {
6248 if let Some(h) = guard.as_ref() {
6249 h.stop();
6250 }
6251 }
6252 if let Ok(mut guard) = self.handles.lock() {
6253 for h in guard.drain(..) {
6254 let _ = h.join();
6255 }
6256 }
6257 }
6258}
6259
6260impl Drop for DcpsRuntime {
6261 // ZERODDS_PHASE_DUMP=1 is on-demand debug telemetry for
6262 // phase-latency profiling. eprintln is semantically correct here
6263 // (stderr diagnostics), no log-crate dependency wanted.
6264 #[allow(clippy::print_stderr)]
6265 fn drop(&mut self) {
6266 if std::env::var("ZERODDS_PHASE_DUMP")
6267 .map(|s| s == "1")
6268 .unwrap_or(false)
6269 {
6270 let hu_ns = PHASE_HANDLE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
6271 let hu_n = PHASE_HANDLE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
6272 let wu_ns = PHASE_WRITE_USER_NS.load(core::sync::atomic::Ordering::Relaxed);
6273 let wu_n = PHASE_WRITE_USER_CALLS.load(core::sync::atomic::Ordering::Relaxed);
6274 let hu_us = if hu_n > 0 {
6275 hu_ns as f64 / hu_n as f64 / 1000.0
6276 } else {
6277 0.0
6278 };
6279 let wu_us = if wu_n > 0 {
6280 wu_ns as f64 / wu_n as f64 / 1000.0
6281 } else {
6282 0.0
6283 };
6284 eprintln!(
6285 "[ZERODDS_PHASE] handle_user_datagram: N={} avg={:.3}us total={:.1}ms",
6286 hu_n,
6287 hu_us,
6288 hu_ns as f64 / 1_000_000.0
6289 );
6290 eprintln!(
6291 "[ZERODDS_PHASE] write_user_sample: N={} avg={:.3}us total={:.1}ms",
6292 wu_n,
6293 wu_us,
6294 wu_ns as f64 / 1_000_000.0
6295 );
6296 // Sub-phases of write_user_sample_borrowed.
6297 // [0] slot_lookup, [1] slot_lock_acquire,
6298 // [2] writer.write + framing, [3] dispatch (UDP + inproc).
6299 const SUB_LABELS: [&str; 4] = [
6300 " ├─ slot_lookup ",
6301 " ├─ slot_lock_acquire ",
6302 " ├─ writer.write+frame",
6303 " └─ dispatch (UDP+...)",
6304 ];
6305 for (i, label) in SUB_LABELS.iter().enumerate() {
6306 let s_ns = PHASE_WRITE_SUB_NS[i].load(core::sync::atomic::Ordering::Relaxed);
6307 if s_ns > 0 && wu_n > 0 {
6308 let s_us = s_ns as f64 / wu_n as f64 / 1000.0;
6309 eprintln!(
6310 "[ZERODDS_PHASE] {} avg={:.3}us total={:.1}ms",
6311 label,
6312 s_us,
6313 s_ns as f64 / 1_000_000.0
6314 );
6315 }
6316 }
6317 }
6318 self.shutdown();
6319 }
6320}
6321
6322// ---------------------------------------------------------------------
6323// Worker threads (Sprint D.5b — per-socket recv + central tick).
6324//
6325// Before: a single `event_loop` that went through three sequential
6326// blocking `recv()`s with a `tick_period` timeout (50 ms) per iteration.
6327// Roundtrip latency: 5-14 ms p50 (CFS drift + sequential wait stages).
6328//
6329// Now: four dedicated threads.
6330// * recv_spdp_multicast_loop — blocks on the SPDP multicast socket
6331// * recv_metatraffic_loop — blocks on SPDP unicast (= metatraffic)
6332// * recv_user_data_loop — blocks on user-data unicast
6333// * tick_loop — periodic outbound tasks +
6334// per-interface inbound (non-blocking) +
6335// deadline/lifespan/liveliness
6336//
6337// Lock discipline: the recv threads and the tick thread contend for
6338// `rt.sedp.lock()` / `rt.wlp.lock()` / per-slot `slot.lock()`.
6339// Convention: keep lock-hold times short (handle_datagram + tick each
6340// have only single-pass logic), no sub-lock under sedp/wlp.
6341// ---------------------------------------------------------------------
6342
6343/// Sprint D.5d lever C — applies SCHED_FIFO + CPU affinity to the
6344/// calling thread. Linux-only; no-op on macOS/Windows.
6345///
6346/// Called by every worker loop right at the start, so
6347/// the syscalls run on the actual worker thread
6348/// (`pthread_self()` must come from the thread itself).
6349///
6350/// Failures are logged to stderr but are not fatal — if
6351/// the process has no `CAP_SYS_NICE`, the runtime continues with
6352/// the CFS default scheduler.
6353#[allow(unused_variables)]
6354fn apply_thread_tuning(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
6355 #[cfg(target_os = "linux")]
6356 rt_pinning::apply(label, priority, cpus);
6357}
6358
6359/// Linux-only `pthread_setschedparam` + `sched_setaffinity` wrapper.
6360/// A dedicated module encapsulates the `unsafe` locally with safety notes; the
6361/// crate-level `#![deny(unsafe_code)]` stays active for the rest of the dcps
6362/// codebase.
6363#[cfg(target_os = "linux")]
6364#[allow(unsafe_code, clippy::print_stderr)]
6365mod rt_pinning {
6366 pub(super) fn apply(label: &str, priority: Option<i32>, cpus: Option<&[usize]>) {
6367 if let Some(prio) = priority {
6368 // SAFETY: libc FFI with an owned `param` struct. The self-thread via
6369 // `pthread_self()` is always valid.
6370 // musl libc has additional `sched_ss_*` fields (POSIX
6371 // sporadic-server) that we do not set — `mem::zeroed`
6372 // initializes them cleanly to 0.
6373 unsafe {
6374 let mut param: libc::sched_param = core::mem::zeroed();
6375 param.sched_priority = prio;
6376 let rc = libc::pthread_setschedparam(
6377 libc::pthread_self(),
6378 libc::SCHED_FIFO,
6379 &raw const param,
6380 );
6381 if rc != 0 {
6382 eprintln!(
6383 "zdds[{label}]: pthread_setschedparam SCHED_FIFO {prio} \
6384 failed (rc={rc}). Need CAP_SYS_NICE or RLIMIT_RTPRIO."
6385 );
6386 }
6387 }
6388 }
6389 if let Some(cpu_list) = cpus {
6390 // SAFETY: cpu_set_t is POD; CPU_ZERO/SET are libc inline
6391 // functions without lifetime requirements.
6392 unsafe {
6393 let mut set: libc::cpu_set_t = core::mem::zeroed();
6394 libc::CPU_ZERO(&mut set);
6395 for &cpu in cpu_list {
6396 if cpu < libc::CPU_SETSIZE as usize {
6397 libc::CPU_SET(cpu, &mut set);
6398 }
6399 }
6400 let rc = libc::sched_setaffinity(
6401 0,
6402 core::mem::size_of::<libc::cpu_set_t>(),
6403 &raw const set,
6404 );
6405 if rc != 0 {
6406 eprintln!("zdds[{label}]: sched_setaffinity({cpu_list:?}) failed.");
6407 }
6408 }
6409 }
6410 }
6411}
6412
6413/// FastDDS interop (phase 2): acknowledges FastDDS' reliable secure SPDP writer
6414/// (0xff0101c2). FastDDS heartbeats its secure SPDP reliably and sends the
6415/// `participant_crypto_tokens` only once our 0xff0101c7 reader has acked its writer
6416/// (fast<->fast reference pcap: ACKNACK on 0xff0101c7). We respond to
6417/// every incoming secure-SPDP HEARTBEAT with an ACKNACK (base = last+1,
6418/// final), addressed via INFO_DST to the sender prefix. Gated on
6419/// `enable_secure_spdp`.
6420#[cfg(feature = "security")]
6421fn secure_spdp_reader_acks(rt: &DcpsRuntime, clear: &[u8]) -> Vec<Vec<u8>> {
6422 use zerodds_rtps::header::RtpsHeader;
6423 use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
6424 use zerodds_rtps::submessages::{AckNackSubmessage, HeartbeatSubmessage, SequenceNumberSet};
6425 use zerodds_rtps::wire_types::SequenceNumber;
6426 if !rt.config.enable_secure_spdp {
6427 return Vec::new();
6428 }
6429 let Ok(parsed) = decode_datagram(clear) else {
6430 return Vec::new();
6431 };
6432 let peer_prefix = parsed.header.guid_prefix;
6433 let mut out = Vec::new();
6434 let mut count = 0i32;
6435 let secure_writer = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER;
6436 let secure_reader = EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER;
6437 // Header + INFO_DST(peer) + submessage. INFO_DST is mandatory, otherwise the
6438 // dest prefix is UNKNOWN -> FastDDS discards it as "not a connection".
6439 let wrap = |id: SubmessageId, body: &[u8], flags: u8| -> Option<Vec<u8>> {
6440 let blen = u16::try_from(body.len()).ok()?;
6441 let header = RtpsHeader::new(VendorId::ZERODDS, rt.guid_prefix);
6442 let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
6443 dg.extend_from_slice(&header.to_bytes());
6444 let info = SubmessageHeader {
6445 submessage_id: SubmessageId::InfoDst,
6446 flags: FLAG_E_LITTLE_ENDIAN,
6447 octets_to_next_header: 12,
6448 };
6449 dg.extend_from_slice(&info.to_bytes());
6450 dg.extend_from_slice(&peer_prefix.to_bytes());
6451 let sh = SubmessageHeader {
6452 submessage_id: id,
6453 flags: flags | FLAG_E_LITTLE_ENDIAN,
6454 octets_to_next_header: blen,
6455 };
6456 dg.extend_from_slice(&sh.to_bytes());
6457 dg.extend_from_slice(body);
6458 Some(dg)
6459 };
6460 for sub in &parsed.submessages {
6461 match sub {
6462 // FastDDS' secure-SPDP writer HEARTBEAT -> we ack (reader 0xff0101c7).
6463 ParsedSubmessage::Heartbeat(hb) if hb.writer_id == secure_writer => {
6464 count = count.wrapping_add(1);
6465 let ack = AckNackSubmessage {
6466 reader_id: secure_reader,
6467 writer_id: secure_writer,
6468 reader_sn_state: SequenceNumberSet {
6469 bitmap_base: SequenceNumber(hb.last_sn.0 + 1),
6470 num_bits: 0,
6471 bitmap: Vec::new(),
6472 },
6473 count,
6474 final_flag: true,
6475 };
6476 let (body, flags) = ack.write_body(true);
6477 if let Some(dg) = wrap(SubmessageId::AckNack, &body, flags) {
6478 out.push(dg);
6479 }
6480 }
6481 // FastDDS' reader requests (preemptive ACKNACK to our 0xff0101c2
6482 // writer) our secure-SPDP data reliably -> deliver DATA(SN=1) +
6483 // HEARTBEAT(1,1), otherwise FastDDS' reader never matches and
6484 // sends no crypto_tokens.
6485 ParsedSubmessage::AckNack(a) if a.writer_id == secure_writer => {
6486 if let Ok(mut beacon) = rt.spdp_beacon.lock() {
6487 if let Ok(data_dg) = beacon.serialize_secure() {
6488 out.push(protect_secure_spdp(rt, &data_dg).unwrap_or(data_dg));
6489 }
6490 }
6491 count = count.wrapping_add(1);
6492 let hbsm = HeartbeatSubmessage {
6493 reader_id: secure_reader,
6494 writer_id: secure_writer,
6495 first_sn: SequenceNumber(1),
6496 last_sn: SequenceNumber(1),
6497 count,
6498 final_flag: false,
6499 liveliness_flag: false,
6500 group_info: None,
6501 };
6502 let (body, flags) = hbsm.write_body(true);
6503 if let Some(dg) = wrap(SubmessageId::Heartbeat, &body, flags) {
6504 out.push(dg);
6505 }
6506 }
6507 _ => {}
6508 }
6509 }
6510 out
6511}
6512
6513/// FastDDS interop (phase 2b): builds a secure-SPDP HEARTBEAT (writer
6514/// 0xff0101c2, first=1/last=1) with INFO_DST to `peer_prefix`. Sent periodically per
6515/// discovered peer, so FastDDS' reliable secure-SPDP reader is solicited to a
6516/// (preemptive) ACKNACK and matches our writer.
6517#[cfg(feature = "security")]
6518fn build_secure_spdp_heartbeat(
6519 local_prefix: GuidPrefix,
6520 peer_prefix: GuidPrefix,
6521 count: i32,
6522) -> Option<Vec<u8>> {
6523 use zerodds_rtps::header::RtpsHeader;
6524 use zerodds_rtps::submessage_header::{FLAG_E_LITTLE_ENDIAN, SubmessageHeader, SubmessageId};
6525 use zerodds_rtps::submessages::HeartbeatSubmessage;
6526 use zerodds_rtps::wire_types::SequenceNumber;
6527 let hb = HeartbeatSubmessage {
6528 reader_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
6529 writer_id: EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
6530 first_sn: SequenceNumber(1),
6531 last_sn: SequenceNumber(1),
6532 count,
6533 final_flag: false,
6534 liveliness_flag: false,
6535 group_info: None,
6536 };
6537 let (body, flags) = hb.write_body(true);
6538 let blen = u16::try_from(body.len()).ok()?;
6539 let header = RtpsHeader::new(VendorId::ZERODDS, local_prefix);
6540 let mut dg = Vec::with_capacity(20 + 16 + body.len() + 4);
6541 dg.extend_from_slice(&header.to_bytes());
6542 let info = SubmessageHeader {
6543 submessage_id: SubmessageId::InfoDst,
6544 flags: FLAG_E_LITTLE_ENDIAN,
6545 octets_to_next_header: 12,
6546 };
6547 dg.extend_from_slice(&info.to_bytes());
6548 dg.extend_from_slice(&peer_prefix.to_bytes());
6549 let sh = SubmessageHeader {
6550 submessage_id: SubmessageId::Heartbeat,
6551 flags: flags | FLAG_E_LITTLE_ENDIAN,
6552 octets_to_next_header: blen,
6553 };
6554 dg.extend_from_slice(&sh.to_bytes());
6555 dg.extend_from_slice(&body);
6556 Some(dg)
6557}
6558
6559/// FastDDS interop: SEC-protects the secure-SPDP DATA (0xff0101c2) under
6560/// `discovery_protection != NONE` — FastDDS then encrypts the secure-SPDP DATA
6561/// (like the secure SEDP), and a PLAIN secure SPDP is discarded. Wraps
6562/// the DATA submessage with the per-endpoint writer key (0xff0101c2) as
6563/// SEC_PREFIX/BODY/POSTFIX; framing submessages (INFO_*) stay. Without
6564/// discovery_protection (common subset) passthrough. `None` on a crypto error.
6565#[cfg(feature = "security")]
6566fn protect_secure_spdp(rt: &DcpsRuntime, datagram: &[u8]) -> Option<Vec<u8>> {
6567 let gate = rt.config.security.as_ref()?;
6568 if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None
6569 || datagram.len() < 20
6570 {
6571 return Some(datagram.to_vec());
6572 }
6573 let h = local_endpoint_crypto_handle(
6574 rt,
6575 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
6576 true,
6577 )?;
6578 let mut out = datagram[..20].to_vec();
6579 for (id, start, total) in walk_submessages(datagram) {
6580 let submsg = &datagram[start..start + total];
6581 if id == SMID_DATA {
6582 match gate.encode_data_datawriter_by_handle(h, submsg) {
6583 Ok(s) => out.extend_from_slice(&s),
6584 Err(_) => return None,
6585 }
6586 } else {
6587 out.extend_from_slice(submsg);
6588 }
6589 }
6590 Some(out)
6591}
6592
6593/// Worker: blocks on the SPDP multicast socket, dispatches SPDP beacons +
6594/// WLP heartbeats that come in over multicast.
6595fn recv_spdp_multicast_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6596 apply_thread_tuning(
6597 "recv-spdp-mc",
6598 rt.config.recv_thread_priority,
6599 rt.config.recv_thread_cpus.as_deref(),
6600 );
6601 while !stop.load(Ordering::Relaxed) {
6602 let elapsed = rt.start_instant.elapsed();
6603 let sedp_now = Duration::from_secs(elapsed.as_secs())
6604 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6605 let Ok(dg) = rt.spdp_multicast_rx.recv() else {
6606 continue;
6607 };
6608 #[cfg(feature = "security")]
6609 let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6610 #[cfg(not(feature = "security"))]
6611 let clear = secure_inbound_bytes(&rt, &dg.data);
6612 if let Some(clear) = clear {
6613 handle_spdp_datagram(&rt, &clear);
6614 // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
6615 // reliably, otherwise FastDDS sends no crypto_tokens.
6616 #[cfg(feature = "security")]
6617 for ack in secure_spdp_reader_acks(&rt, &clear) {
6618 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
6619 let _ = rt.spdp_unicast.send(&loc, &ack);
6620 }
6621 }
6622 // WLP heartbeats arrive on the SPDP multicast socket
6623 // (the sender sends them to the SPDP multicast group).
6624 // handle_spdp_datagram ignores them, so we also feed
6625 // the same buffer into the WLP endpoint. A
6626 // secure-WLP DATA is participant-key SEC-protected → decode
6627 // it first (like secure SEDP in the metatraffic loop), otherwise
6628 // wlp.handle_datagram would only see the SEC block.
6629 #[cfg(feature = "security")]
6630 let wlp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
6631 let mut pk = [0u8; 12];
6632 pk.copy_from_slice(&clear[8..20]);
6633 unprotect_user_datagram(&rt, &clear, &pk)
6634 } else {
6635 None
6636 };
6637 #[cfg(feature = "security")]
6638 let wlp_input: &[u8] = wlp_decoded.as_deref().unwrap_or(&clear);
6639 #[cfg(not(feature = "security"))]
6640 let wlp_input: &[u8] = &clear;
6641 if let Ok(mut wlp) = rt.wlp.lock() {
6642 let _ = wlp.handle_datagram(wlp_input, sedp_now);
6643 }
6644 }
6645 }
6646}
6647
6648/// Worker: blocks on SPDP unicast (= metatraffic socket), dispatches
6649/// SPDP reverse beacons + SEDP + WLP + security builtin.
6650fn recv_metatraffic_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6651 apply_thread_tuning(
6652 "recv-meta",
6653 rt.config.recv_thread_priority,
6654 rt.config.recv_thread_cpus.as_deref(),
6655 );
6656 while !stop.load(Ordering::Relaxed) {
6657 let elapsed = rt.start_instant.elapsed();
6658 let sedp_now = Duration::from_secs(elapsed.as_secs())
6659 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6660 let Ok(dg) = rt.spdp_unicast.recv() else {
6661 continue;
6662 };
6663 #[cfg(feature = "security")]
6664 let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6665 #[cfg(not(feature = "security"))]
6666 let clear = secure_inbound_bytes(&rt, &dg.data);
6667 if let Some(clear) = clear {
6668 // A single recv call, both handlers on the same
6669 // datagram. SPDP first (Cyclone reverse beacons), then
6670 // SEDP, then WLP, then security builtin.
6671 handle_spdp_datagram(&rt, &clear);
6672 // FastDDS interop phase 2: ack the secure-SPDP HEARTBEATs (0xff0101c2)
6673 // reliably (they arrive unicast over the metatraffic socket).
6674 #[cfg(feature = "security")]
6675 for ack in secure_spdp_reader_acks(&rt, &clear) {
6676 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
6677 let _ = rt.spdp_unicast.send(&loc, &ack);
6678 }
6679 }
6680 // Protected discovery: secure-SEDP DATA is SEC_* submessage-
6681 // protected (the sender's participant data key). Before the SEDP parse
6682 // decode it with the sender prefix (RTPS header bytes[8..20]); for
6683 // plaintext SEDP (no SEC_*) unprotect_user_datagram returns None
6684 // and we use `clear` unchanged.
6685 #[cfg(feature = "security")]
6686 let sedp_decoded: Option<Vec<u8>> = if clear.len() >= 20 {
6687 let mut pk = [0u8; 12];
6688 pk.copy_from_slice(&clear[8..20]);
6689 unprotect_user_datagram(&rt, &clear, &pk)
6690 } else {
6691 None
6692 };
6693 // OPEN (phase 3, docs/security/per-endpoint-crypto-followup.md):
6694 // if `unprotect_user_datagram` fails for a secure-SEDP DATA
6695 // (cyclone's per-endpoint token not yet installed — race),
6696 // `sedp_input` falls back to the SEC_* bytes and the DATA is discarded.
6697 // Cross-vendor (discovery=ENCRYPT) must make this deterministic:
6698 // treat the reliable secure-SEDP DATA as not-received (NACK,
6699 // no SN advance), so the re-send after token install decodes.
6700 #[cfg(feature = "security")]
6701 let sedp_input: &[u8] = sedp_decoded.as_deref().unwrap_or(&clear);
6702 #[cfg(not(feature = "security"))]
6703 let sedp_input: &[u8] = &clear;
6704 let events = {
6705 if let Ok(mut sedp) = rt.sedp.lock() {
6706 sedp.handle_datagram(sedp_input, sedp_now).ok()
6707 } else {
6708 None
6709 }
6710 };
6711 if let Some(ev) = events {
6712 if !ev.is_empty() {
6713 run_matching_pass(&rt);
6714 push_sedp_events_to_builtin_readers(&rt, &ev);
6715 }
6716 }
6717
6718 // Secure WLP (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER) is, like
6719 // secure SEDP, participant-key SEC-protected → feed the decoded variant
6720 // (sedp_input), not the still SEC-wrapped `clear`. For
6721 // plaintext WLP, sedp_input == clear.
6722 let wlp_resends = if let Ok(mut wlp) = rt.wlp.lock() {
6723 let _ = wlp.handle_datagram(sedp_input, sedp_now);
6724 // Reliable resend: if the peer NACKs our (secure-)WLP writer,
6725 // we re-emit the missing beats (cyclone treats WLP as
6726 // reliable; without a resend it would never get the liveliness assertion).
6727 wlp.wlp_acknack_resends(sedp_input)
6728 } else {
6729 Vec::new()
6730 };
6731 for beat in wlp_resends {
6732 if let Some(secured) = protect_wlp_outbound(&rt, &beat) {
6733 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
6734 let _ = rt.spdp_unicast.send(&loc, &secured);
6735 }
6736 }
6737 }
6738 for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
6739 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
6740 }
6741 }
6742 }
6743}
6744
6745/// Worker: wave 4b.4 (Spec `zerodds-zero-copy-1.0` §6) — per-owner
6746/// SHM recv loop. Iterates round-robin over all bound-consumer
6747/// entries of the [`SameHostTracker`](crate::same_host::SameHostTracker)
6748/// and calls `recv()` with the configured per-transport timeout
6749/// (50 ms default). On data, dispatches via [`handle_user_datagram`]
6750/// analogous to the UDP path.
6751///
6752/// Latency tradeoff: with N consumers the worst-case latency
6753/// for a sample is (N-1) × recv_timeout. Acceptable for small
6754/// N (typically <10 same-host peers); for larger topologies
6755/// this would have to be switched to multiple threads or epoll-style
6756/// multiplexing (wave 4b.4 follow-up).
6757#[cfg(feature = "same-host-shm")]
6758fn recv_user_shm_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6759 use crate::same_host::{Role, SameHostState};
6760 use zerodds_transport::Transport;
6761 use zerodds_transport_shm::PosixShmTransport;
6762
6763 apply_thread_tuning(
6764 "recv-shm",
6765 rt.config.recv_thread_priority,
6766 rt.config.recv_thread_cpus.as_deref(),
6767 );
6768 let idle_sleep = Duration::from_millis(100);
6769 while !stop.load(Ordering::Relaxed) {
6770 // SHM bind now happens synchronously in the SEDP hook (transport-shm
6771 // 2026-05-19 idempotent open_or_create). Here only the bound-
6772 // consumer drain — no lazy retry needed anymore.
6773 let consumers: Vec<Arc<PosixShmTransport>> = rt
6774 .same_host
6775 .snapshot()
6776 .into_iter()
6777 .filter_map(|(_, _, state)| match state {
6778 SameHostState::Bound { transport, role } => {
6779 if !matches!(role, Role::Consumer) {
6780 return None;
6781 }
6782 transport.downcast::<PosixShmTransport>().ok()
6783 }
6784 _ => None,
6785 })
6786 .collect();
6787 if consumers.is_empty() {
6788 thread::sleep(idle_sleep);
6789 continue;
6790 }
6791 let elapsed = rt.start_instant.elapsed();
6792 let sedp_now = Duration::from_secs(elapsed.as_secs())
6793 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6794 for consumer in &consumers {
6795 if stop.load(Ordering::Relaxed) {
6796 break;
6797 }
6798 match consumer.recv() {
6799 Ok(dg) => {
6800 // Security gate (analogous to the UDP path). SHM is
6801 // same-host-only — if the policy allows plaintext,
6802 // the datagram comes through unchanged.
6803 #[cfg(feature = "security")]
6804 let clear = secure_inbound_bytes(&rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6805 #[cfg(not(feature = "security"))]
6806 let clear = secure_inbound_bytes(&rt, &dg.data);
6807 if let Some(clear) = clear {
6808 handle_user_datagram(&rt, &clear, sedp_now);
6809 }
6810 }
6811 // A timeout is normal — recv has the configured
6812 // 50 ms limit, an empty segment is not an error.
6813 Err(zerodds_transport::RecvError::Timeout) => {}
6814 Err(_) => {
6815 // Hard error (broken segment, peer crashed).
6816 // We could set the tracker entry to
6817 // Failed here — for the first cut we leave
6818 // it at silence + the UDP fallback
6819 // stays active.
6820 }
6821 }
6822 }
6823 }
6824}
6825
6826/// Worker: blocks on the user-data unicast socket, dispatches
6827/// TypeLookup service replies + user-sample datagrams.
6828///
6829/// Int-1 (Spec `zerodds-zero-copy-1.0` §9): with the feature
6830/// `recvmmsg-batch` on Linux the loop uses `recv_batch_linux` and
6831/// fetches up to 32 datagrams per syscall — a 7-8x throughput boost.
6832/// On an empty batch the path falls back to single-recv() so
6833/// the recv thread does not spin in a busy loop at low traffic.
6834fn recv_user_data_loop(
6835 rt: Arc<DcpsRuntime>,
6836 socket: Arc<dyn Transport + Send + Sync>,
6837 stop: Arc<AtomicBool>,
6838) {
6839 apply_thread_tuning(
6840 "recv-user",
6841 rt.config.recv_thread_priority,
6842 rt.config.recv_thread_cpus.as_deref(),
6843 );
6844 // recvmmsg-batch (Linux + feature) needs the concrete UdpSocket
6845 // under the trait. With a trait-object transport this is not directly
6846 // accessible — we fall back to single-recv(). recvmmsg is
6847 // a UDP optimization; once TCP/SHM transports are to be mixed,
6848 // it is no longer worth it. For a pure UDPv4 user transport
6849 // this costs ~5-10% throughput in Linux batch mode (measured 2026-05).
6850 while !stop.load(Ordering::Relaxed) {
6851 let elapsed = rt.start_instant.elapsed();
6852 let sedp_now = Duration::from_secs(elapsed.as_secs())
6853 + Duration::from_nanos(u64::from(elapsed.subsec_nanos()));
6854 let Ok(dg) = socket.recv() else {
6855 continue;
6856 };
6857 dispatch_user_datagram(&rt, &dg, sedp_now);
6858 // D.5e Phase 3 — incoming user data may solicit an ACKNACK or advance a
6859 // reliable reader: wake the scheduler tick immediately (no 5 ms tail).
6860 rt.raise_tick_wake();
6861 }
6862}
6863
6864/// Helper: dispatches a single user datagram through the security gate +
6865/// TypeLookup + handle_user_datagram. Shared by the single-recv and the
6866/// recvmmsg batch path.
6867fn dispatch_user_datagram(
6868 rt: &Arc<DcpsRuntime>,
6869 dg: &zerodds_transport::ReceivedDatagram,
6870 sedp_now: Duration,
6871) {
6872 #[cfg(feature = "security")]
6873 let clear = secure_inbound_bytes(rt, &dg.data, &DEFAULT_INBOUND_IFACE);
6874 #[cfg(not(feature = "security"))]
6875 let clear = secure_inbound_bytes(rt, &dg.data);
6876 if let Some(clear) = clear {
6877 // TypeLookup service first — if the frame is addressed to
6878 // TL_SVC_*_READER, it does not go to a
6879 // user reader. Other frames fall through.
6880 if !dispatch_type_lookup_datagram(rt, &clear, &dg.source) {
6881 handle_user_datagram(rt, &clear, sedp_now);
6882 }
6883 }
6884}
6885
6886/// Worker: periodic outbound tasks + per-interface inbound
6887/// (non-blocking) + housekeeping. Sleeps `tick_period` between
6888/// iterations.
6889fn tick_loop(rt: Arc<DcpsRuntime>, stop: Arc<AtomicBool>) {
6890 apply_thread_tuning(
6891 "tick",
6892 rt.config.tick_thread_priority,
6893 rt.config.tick_thread_cpus.as_deref(),
6894 );
6895 let mut st = TickState::new(&rt);
6896 while !stop.load(Ordering::Relaxed) {
6897 run_tick_iteration(Arc::clone(&rt), &mut st);
6898 // Housekeeping runs inline here in the classic fixed-period path,
6899 // exactly as before (every `tick_period`, same cadence).
6900 tick_housekeep(&rt, rt.start_instant.elapsed());
6901 std::thread::sleep(rt.config.tick_period);
6902 }
6903}
6904
6905/// D.5e Phase 3 — idle park cap for a discovery-only participant (no user
6906/// endpoints): how long the scheduler tick worker may sleep when nothing but
6907/// SPDP/WLP is pending. SPDP/WLP fire on their own (longer) periods, so this is
6908/// just a safety heartbeat — well above the 5 ms poll it replaces.
6909const SCHEDULER_IDLE_FLOOR: Duration = Duration::from_millis(250);
6910
6911/// Earliest instant the scheduler tick worker must next run `run_tick_iteration`
6912/// so no periodic work is delayed: never past the next SPDP announce, and —
6913/// while user endpoints exist — capped at `tick_period` so HEARTBEAT/ACKNACK/
6914/// deadline/lifespan/liveliness keep their current cadence (identical wire
6915/// behaviour). With no user endpoints, parks up to [`SCHEDULER_IDLE_FLOOR`].
6916/// Active traffic is handled out-of-band by `raise_tick_wake` (immediate).
6917fn next_tick_deadline(rt: &Arc<DcpsRuntime>, st: &TickState) -> Instant {
6918 let now = Instant::now();
6919 let fine_cap = if rt.has_user_endpoints() {
6920 rt.config.tick_period
6921 } else {
6922 SCHEDULER_IDLE_FLOOR
6923 };
6924 st.next_announce.min(now + fine_cap).max(now)
6925}
6926
6927/// D.5e Phase 3 B-2 — the kinds of work the deadline-heap scheduler fires as
6928/// distinct heap events, each re-armed at its own next deadline.
6929#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6930enum TickEvent {
6931 /// Periodic SPDP announce + reliable outbound (SEDP / WLP / user HEARTBEAT /
6932 /// ACKNACK) + secondary inbound poll — the wire-producing tick
6933 /// ([`run_tick_iteration`]), re-armed at [`next_tick_deadline`].
6934 Tick,
6935 /// Deadline / lifespan / liveliness housekeeping ([`tick_housekeep`]),
6936 /// re-armed at the **exact** next QoS due-instant (no fixed quantum).
6937 Housekeep,
6938}
6939
6940/// D.5e Phase 3 — event-driven scheduler tick worker. Replaces the fixed-period
6941/// `tick_loop` sleep with a deadline-heap park. Two independent event streams:
6942/// [`TickEvent::Tick`] drives the **unchanged** `run_tick_iteration` (wire
6943/// output byte-identical to `tick_loop`), re-armed at [`next_tick_deadline`];
6944/// [`TickEvent::Housekeep`] runs the QoS checks, re-armed at their exact next
6945/// due-instant so a deadline/lifespan/liveliness fires on time instead of up to
6946/// one `tick_period` late, and an idle participant parks long. A write/recv
6947/// `raise_tick_wake` wakes **both** immediately, so freshly-armed QoS windows
6948/// are picked up without delay.
6949fn scheduler_tick_loop(
6950 rt: Arc<DcpsRuntime>,
6951 stop: Arc<AtomicBool>,
6952 mut scheduler: crate::scheduler::Scheduler<TickEvent>,
6953 handle: crate::scheduler::SchedulerHandle<TickEvent>,
6954) {
6955 apply_thread_tuning(
6956 "tick",
6957 rt.config.tick_thread_priority,
6958 rt.config.tick_thread_cpus.as_deref(),
6959 );
6960 let mut st = TickState::new(&rt);
6961 // Prime both event streams immediately.
6962 handle.raise_now(TickEvent::Tick);
6963 handle.raise_now(TickEvent::Housekeep);
6964 loop {
6965 let (due, stopped) = scheduler.park_due_batch();
6966 if stopped || stop.load(Ordering::Relaxed) {
6967 break;
6968 }
6969 if due.is_empty() {
6970 continue; // woken with nothing due yet — re-evaluate.
6971 }
6972 // Coalesce: a batch of wakes maps to at most ONE run of each kind.
6973 let mut do_tick = false;
6974 let mut do_housekeep = false;
6975 for ev in due {
6976 match ev {
6977 TickEvent::Tick => do_tick = true,
6978 TickEvent::Housekeep => do_housekeep = true,
6979 }
6980 }
6981 if do_tick {
6982 rt.tick_wake_pending.store(false, Ordering::Release);
6983 run_tick_iteration(Arc::clone(&rt), &mut st);
6984 if stop.load(Ordering::Relaxed) {
6985 break;
6986 }
6987 handle.raise_at(next_tick_deadline(&rt, &st), TickEvent::Tick);
6988 }
6989 if do_housekeep {
6990 let next = tick_housekeep(&rt, rt.start_instant.elapsed());
6991 if stop.load(Ordering::Relaxed) {
6992 break;
6993 }
6994 // Park exactly until the next QoS due-instant; nothing pending →
6995 // idle floor (a later write re-arms via `raise_tick_wake`).
6996 let deadline = match next {
6997 Some(due_nanos) => rt.start_instant + Duration::from_nanos(due_nanos),
6998 None => Instant::now() + SCHEDULER_IDLE_FLOOR,
6999 };
7000 handle.raise_at(deadline, TickEvent::Housekeep);
7001 }
7002 }
7003}
7004
7005/// Per-iteration mutable state of the runtime tick. Held across iterations so
7006/// the same body ([`run_tick_iteration`]) can be driven from either the
7007/// dedicated `zdds-tick` thread (default) or an external executor — tokio via
7008/// [`DcpsRuntime::tick_driver`] / async `spawn_in_tokio`
7009/// (zerodds-async-1.0 §4).
7010struct TickState {
7011 /// Multicast target locator to which we send SPDP beacons.
7012 mc_target: Locator,
7013 /// Next instant at which a periodic SPDP announce is due.
7014 next_announce: Instant,
7015 /// Number of SPDP announces already sent. Drives the C3 initial
7016 /// announcement burst: as long as `< initial_announce_count` **and** no
7017 /// peer discovered yet, announces happen at `initial_announce_period` cadence
7018 /// instead of the full `spdp_period` — so discovery over lossy/power-save WiFi
7019 /// does not fail on lost first beacons.
7020 announces_done: u32,
7021 /// FastDDS interop: count for the periodic secure-SPDP HEARTBEATs
7022 /// (0xff0101c2). Must increase, otherwise FastDDS' reader ignores follow-up HBs.
7023 #[cfg(feature = "security")]
7024 secure_hb_count: i32,
7025}
7026
7027impl TickState {
7028 fn new(rt: &Arc<DcpsRuntime>) -> Self {
7029 let mc_target = Locator {
7030 kind: LocatorKind::UdpV4,
7031 port: u32::from(
7032 u16::try_from(spdp_multicast_port(rt.domain_id as u32)).unwrap_or(7400),
7033 ),
7034 address: {
7035 let mut a = [0u8; 16];
7036 a[12..].copy_from_slice(&rt.config.spdp_multicast_group.octets());
7037 a
7038 },
7039 };
7040 Self {
7041 mc_target,
7042 next_announce: Instant::now(), // immediately at start
7043 announces_done: 0,
7044 #[cfg(feature = "security")]
7045 secure_hb_count: 0,
7046 }
7047 }
7048}
7049
7050/// One iteration of the runtime's **wire** tick: periodic SPDP announce,
7051/// SEDP/WLP ticks, per-user-writer + per-user-reader ticks, secondary inbound
7052/// poll. QoS housekeeping (deadline/lifespan/liveliness) is **not** part of this
7053/// — each driver calls [`tick_housekeep`] separately (D.5e Phase 3 B-2), so the
7054/// event-driven scheduler can fire it on its own exact-deadline schedule.
7055/// Mutable per-iteration state lives in `st`; the caller waits `tick_period`
7056/// between calls. Factored out of [`tick_loop`] so an external executor can
7057/// drive the tick without the dedicated thread (zerodds-async-1.0 §4).
7058fn run_tick_iteration(rt: Arc<DcpsRuntime>, st: &mut TickState) {
7059 // Monotonic clock relative to runtime start. Used by the SEDP,
7060 // WLP and user tick alike.
7061 let elapsed_since_start = rt.start_instant.elapsed();
7062 let sedp_now = Duration::from_secs(elapsed_since_start.as_secs())
7063 + Duration::from_nanos(u64::from(elapsed_since_start.subsec_nanos()));
7064
7065 // --- Periodic SPDP announce ---
7066 // FU2 cross-vendor (cyclone-trace-documented): a secured participant MUST
7067 // NOT announce before its security builtins are enabled — otherwise
7068 // a token-less/non-secure first beacon goes out, which foreign vendors
7069 // (cyclone: "Non secure remote ... not allowed by security") latch as
7070 // non-secure and, on the later token beacon, treat ONLY as a QoS update
7071 // (no security re-evaluation) → the handshake never starts.
7072 // `config.security.is_some()` = secured runtime; until
7073 // `enable_security_builtins*` installs the stack (snapshot Some) +
7074 // sets the token/security-info on the beacon, we hold the beacon
7075 // back. enable() triggers the first token-carrying beacon via
7076 // `announce_spdp_now()`. Plain runtimes (security None) announce
7077 // immediately as before.
7078 #[cfg(feature = "security")]
7079 let security_pending = rt.config.security.is_some() && rt.security_builtin_snapshot().is_none();
7080 #[cfg(not(feature = "security"))]
7081 let security_pending = false;
7082 if Instant::now() >= st.next_announce && !security_pending {
7083 let secured_beacon: Option<Vec<u8>> = {
7084 if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7085 beacon
7086 .serialize()
7087 .ok()
7088 .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7089 } else {
7090 None
7091 }
7092 };
7093 if let Some(secured) = secured_beacon {
7094 let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7095 // C1 multicast-free discovery: additionally to all configured
7096 // initial peers (ZERODDS_PEERS) — bootstrap without multicast.
7097 rt.send_spdp_to_initial_peers(&secured);
7098 // SPDP unicast fan-out to discovered peers (analogous to WLP-M-2/H-3-H-4):
7099 // codepit-LXC multicast is flaky; if it loses the tokened
7100 // secure beacon, the peer never discovers ZeroDDS as secure and
7101 // NEVER starts the auth handshake (cyclone→ZeroDDS responder hung
7102 // exactly here: HS_DISPATCH=0). From the metatraffic recv socket
7103 // (spdp_unicast), so the source port is correct.
7104 // Periodic directed unicast fan-out to discovered peers:
7105 // codepit-LXC multicast is flaky; if it loses the tokened
7106 // beacon, the peer never discovers ZeroDDS as secure and never starts
7107 // the auth handshake. The unicast refresh (every spdp_period) robustly
7108 // covers lost multicasts + late joiners. (Previously disabled for a
7109 // flaky-diag experiment — reactivated as a regular path,
7110 // complements the event-driven directed response in handle_spdp_datagram.)
7111 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7112 let _ = rt.spdp_unicast.send(&loc, &secured);
7113 }
7114 }
7115 // FastDDS interop: announce in parallel on the reliable secure-SPDP writer
7116 // (0xff0101c2). FastDDS announces its full secured
7117 // participant data over this channel and gates the crypto-token
7118 // reciprocation on it; without our secure SPDP it never sees ZeroDDS there
7119 // and reciprocates no datawriter/datareader tokens.
7120 #[cfg(feature = "security")]
7121 if rt.config.enable_secure_spdp {
7122 let secure_beacon: Option<Vec<u8>> = {
7123 if let Ok(mut beacon) = rt.spdp_beacon.lock() {
7124 beacon
7125 .serialize_secure()
7126 .ok()
7127 .and_then(|d| protect_secure_spdp(&rt, &d))
7128 .and_then(|d| secure_outbound_bytes(&rt, &d).map(|c| c.to_vec()))
7129 } else {
7130 None
7131 }
7132 };
7133 if let Some(secured) = secure_beacon {
7134 let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7135 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7136 let _ = rt.spdp_unicast.send(&loc, &secured);
7137 }
7138 }
7139 // Secure-SPDP HEARTBEAT per peer (INFO_DST), so FastDDS' reader
7140 // — even as a late joiner — is solicited to a (preemptive) ACKNACK
7141 // and matches our 0xff0101c2 writer. Without a HEARTBEAT
7142 // FastDDS does not engage our writer (fastdds->zerodds: 0 ACKNACK).
7143 st.secure_hb_count = st.secure_hb_count.wrapping_add(1);
7144 for p in rt.discovered_participants() {
7145 let peer_prefix = p.data.guid.prefix;
7146 if let Some(hb) =
7147 build_secure_spdp_heartbeat(rt.guid_prefix, peer_prefix, st.secure_hb_count)
7148 {
7149 for loc in wlp_unicast_targets(core::slice::from_ref(&p)) {
7150 let _ = rt.spdp_unicast.send(&loc, &hb);
7151 }
7152 }
7153 }
7154 }
7155 // C3 WiFi robustness — initial announcement burst: as long as we have
7156 // not discovered a peer yet and the burst count is not exhausted,
7157 // announce at the fast `initial_announce_period` cadence. Over
7158 // lossy/power-save WiFi the first beacons often get lost in the cold-start
7159 // or sleep window; a single announce + 5s period
7160 // then leads to `participants=0`. The burst keeps the NIC awake through
7161 // frequent TX, keeps the stateful-firewall pinhole open and
7162 // elicits directed SPDP responses that arrive in the wake windows
7163 // — analogous to FastDDS `initial_announcements`. As soon as a peer
7164 // is discovered, the cadence falls back to the full `spdp_period`.
7165 st.announces_done = st.announces_done.saturating_add(1);
7166 rt.spdp_announce_seq.fetch_add(1, Ordering::Relaxed);
7167 let still_searching = st.announces_done < rt.config.initial_announce_count
7168 && rt.discovered_participants().is_empty();
7169 let period = if still_searching {
7170 rt.config.initial_announce_period
7171 } else {
7172 rt.config.spdp_period
7173 };
7174 st.next_announce = Instant::now() + period;
7175 }
7176
7177 // (SPDP multicast recv: now in `recv_spdp_multicast_loop`.)
7178
7179 // --- SEDP-Tick (outbound HEARTBEAT/Resend/ACKNACK) ---
7180 let sedp_outbound = {
7181 if let Ok(mut sedp) = rt.sedp.lock() {
7182 sedp.tick(sedp_now).unwrap_or_default()
7183 } else {
7184 Vec::new()
7185 }
7186 };
7187 for dg in sedp_outbound {
7188 // Protected discovery: SEC_*-protect secure-SEDP DATA/HEARTBEAT/GAP
7189 // (participant data key). Non-secure SEDP goes unchanged; on a
7190 // crypto error on secure SEDP it is dropped (no plaintext leak).
7191 #[cfg(feature = "security")]
7192 {
7193 if let Some(inner) = protect_sedp_outbound(&rt, &dg.bytes) {
7194 // discovery_protection has SEC-wrapped the secure SEDP per-submessage
7195 // (SEC_PREFIX/BODY/POSTFIX, per-endpoint key). Under
7196 // rtps_protection SRTPS MUST additionally go on top — BOTH layers,
7197 // like cyclone<->cyclone (reference pcap: 0x "clear submsg from
7198 // protected src"). send_discovery_datagram -> secure_outbound_bytes
7199 // would classify the SEC_PREFIX datagram as volatile-Kx (which is
7200 // RIGHTLY SRTPS-exempt, because its key only comes over the volatile
7201 // itself) and skip SRTPS -> cyclone would see the
7202 // secure SEDP clear, discard ACKNACK/HEARTBEAT as "clear submsg
7203 // from protected src" and never re-send the SubscriptionData ->
7204 // ZeroDDS' writer never matches cyclone's reader (wait_for_matched
7205 // timeout). Hence wrap SRTPS EXPLICITLY here instead of via the
7206 // generic exempt heuristic.
7207 let final_bytes: Option<Vec<u8>> = match &rt.config.security {
7208 Some(gate)
7209 if gate.rtps_protection().unwrap_or(ProtectionLevel::None)
7210 != ProtectionLevel::None =>
7211 {
7212 gate.transform_outbound(&inner).ok()
7213 }
7214 _ => Some(inner),
7215 };
7216 if let Some(fb) = final_bytes {
7217 for t in dg.targets.iter() {
7218 if is_routable_user_locator(t) {
7219 let _ = rt.spdp_unicast.send(t, &fb);
7220 }
7221 }
7222 }
7223 }
7224 }
7225 #[cfg(not(feature = "security"))]
7226 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7227 }
7228
7229 // --- Security-Builtin-Tick ---
7230 // Volatile-Secure-Writer heartbeats + Volatile-Secure-Reader
7231 // ACKNACK/NACK_FRAG. Stateless hat keinen Tick (BestEffort).
7232 if let Some(stack) = rt.security_builtin_snapshot() {
7233 let outbound = {
7234 if let Ok(mut s) = stack.lock() {
7235 // `out` is only mutated under feature="security" (reassign +
7236 // extend in the cfg block below); otherwise unused_mut in the no-security build.
7237 #[allow(unused_mut)]
7238 let mut out = s.poll(sedp_now).unwrap_or_default();
7239 #[cfg(feature = "security")]
7240 if rt.config.security.is_some() {
7241 // STABLE peer list: `completed_peer_prefixes()` reads
7242 // `self.handshakes`, which is GC'd after handshake completion
7243 // → the LATE volatile RESENDS/HEARTBEATs (tick, long after
7244 // completion) would then find NO peer anymore (`peers.len()!=1`)
7245 // and go out CLEAR → cyclone discards them as "clear
7246 // submsg from protected src". The stabler `authenticated_peer_
7247 // prefixes()` (the installed Kx key stays) — identical to the
7248 // token-send tick further below.
7249 let peers: Vec<GuidPrefix> = rt
7250 .config
7251 .security
7252 .as_ref()
7253 .map(|g| {
7254 g.authenticated_peer_prefixes()
7255 .into_iter()
7256 .map(GuidPrefix::from_bytes)
7257 .collect()
7258 })
7259 .unwrap_or_default();
7260 // The reliable volatile submessages from poll() (DATA RESENDS
7261 // + HEARTBEAT + GAP) must — like the first send — be SEC_*-
7262 // protected (§8.4.2.4, all writer submessages incl.
7263 // HEARTBEAT). protect_volatile_datagram now protects all
7264 // is_protected_writer_submessage. With exactly one peer
7265 // (bench) with its Kx key.
7266 if peers.len() == 1 {
7267 let pk = peers[0].to_bytes();
7268 out = out
7269 .into_iter()
7270 .filter_map(|dg| {
7271 protect_volatile_datagram(&rt, &dg.bytes, &pk).map(|bytes| {
7272 zerodds_rtps::message_builder::OutboundDatagram {
7273 bytes,
7274 targets: dg.targets,
7275 }
7276 })
7277 })
7278 .collect();
7279 }
7280 // FU2 step 6b: send per-endpoint datawriter/datareader crypto
7281 // tokens to every authenticated peer as soon as the
7282 // local user endpoints exist.
7283 //
7284 // STABLE peer list instead of `completed_peer_prefixes()`: the
7285 // handshake entry is GC'd after completion, so a
7286 // late-matching user writer/reader (user endpoints match
7287 // AFTER the secure SEDP) would find no tick window in which
7288 // its per-endpoint token would go out — the peer could then never
7289 // decode ZeroDDS' user DATA (#29). `authenticated_peer_
7290 // prefixes()` (the installed data key) stays.
7291 let token_peers: Vec<GuidPrefix> = rt
7292 .config
7293 .security
7294 .as_ref()
7295 .map(|g| {
7296 g.authenticated_peer_prefixes()
7297 .into_iter()
7298 .map(GuidPrefix::from_bytes)
7299 .collect()
7300 })
7301 .unwrap_or_default();
7302 for prefix in token_peers {
7303 // Per-token dedup (#29): each per-endpoint token
7304 // exactly once — builtins early, user endpoints
7305 // as soon as they match. A per-peer guard would
7306 // block late-matched user endpoints forever.
7307 let already = rt
7308 .endpoint_tokens_sent
7309 .read()
7310 .map(|set| set.clone())
7311 .unwrap_or_default();
7312 let pending = pending_endpoint_tokens(
7313 prepare_endpoint_crypto_tokens(&rt, prefix),
7314 &already,
7315 );
7316 for ep_msg in pending {
7317 let key = endpoint_token_key(&ep_msg);
7318 out.extend(protect_volatile_outbound(
7319 &rt,
7320 prefix,
7321 s.volatile_writer
7322 .write_with_heartbeat(&ep_msg, sedp_now)
7323 .unwrap_or_default(),
7324 ));
7325 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
7326 set.insert(key);
7327 }
7328 }
7329 }
7330 }
7331 out
7332 } else {
7333 Vec::new()
7334 }
7335 };
7336 for dg in outbound {
7337 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7338 }
7339 }
7340
7341 // --- WLP-Tick (Writer-Liveliness-Protocol Heartbeats) ---
7342 //
7343 // RTPS 2.5 §8.4.13: WLP heartbeats are metatraffic.
7344 // Spec recommendation: multicast to all known peers, one
7345 // heartbeat per `lease_duration / 3`. We send via the
7346 // SPDP multicast sender — that is the same socket that
7347 // sends out the SPDP beacons, and it ensures that all
7348 // peers see the WLP pulses without the runtime having to
7349 // look up a unicast locator per peer.
7350 let wlp_outbound = {
7351 if let Ok(mut wlp) = rt.wlp.lock() {
7352 // Use the secure-WLP entity when liveliness_protection != NONE
7353 // (set idempotently per tick — follows the current governance).
7354 wlp.set_secure(wlp_liveliness_protected(&rt));
7355 wlp.tick(sedp_now).unwrap_or(None)
7356 } else {
7357 None
7358 }
7359 };
7360 if let Some(bytes) = wlp_outbound {
7361 // Under liveliness_protection != NONE the secure-WLP DATA is protected
7362 // with the participant key (§8.4.2.4); otherwise rtps-level/plaintext.
7363 if let Some(secured) = protect_wlp_outbound(&rt, &bytes) {
7364 // Multicast to all peers (spec recommendation §8.4.13)...
7365 let _ = rt.spdp_mc_tx.send(&st.mc_target, &secured);
7366 // ...plus unicast to every discovered peer (M-2), so WLP also
7367 // arrives without multicast (container/cloud). From the metatraffic recv
7368 // socket (spdp_unicast), so the source port is correct (cf. H-3/H-4).
7369 for loc in wlp_unicast_targets(&rt.discovered_participants()) {
7370 let _ = rt.spdp_unicast.send(&loc, &secured);
7371 }
7372 }
7373 }
7374
7375 // (Metatraffic unicast recv: now in `recv_metatraffic_loop`.)
7376
7377 // --- User-Writer-Tick (HEARTBEAT + Resends) ---
7378 //
7379 // Security: per-target serializer. A datagram can go to
7380 // multiple reader locators. Per target we pull it
7381 // individually through `secure_outbound_for_target`, so the
7382 // wire payload matches the protection class of the respective reader.
7383 let user_writer_outbound: Vec<(EntityId, _)> = {
7384 let mut all = Vec::new();
7385 for (eid, arc) in rt.writer_slots_snapshot() {
7386 if let Ok(mut slot) = arc.lock() {
7387 if let Ok(dgs) = slot.writer.tick(sedp_now) {
7388 for dg in dgs {
7389 all.push((eid, dg));
7390 }
7391 }
7392 }
7393 }
7394 all
7395 };
7396 for (writer_eid, dg) in user_writer_outbound {
7397 for t in dg.targets.iter() {
7398 if !is_routable_user_locator(t) {
7399 continue;
7400 }
7401 if let Some(secured) = secure_outbound_for_target(&rt, writer_eid, &dg.bytes, t) {
7402 send_on_best_interface(&rt, t, &secured);
7403 }
7404 }
7405 }
7406
7407 // --- User-Reader-Tick-Outbound (ACKNACK / NACK_FRAG) ---
7408 let user_reader_outbound: Vec<_> = {
7409 let mut all = Vec::new();
7410 for (_eid, arc) in rt.reader_slots_snapshot() {
7411 if let Ok(mut slot) = arc.lock() {
7412 if let Ok(dgs) = slot.reader.tick_outbound(sedp_now) {
7413 all.extend(dgs);
7414 }
7415 }
7416 }
7417 all
7418 };
7419 for dg in user_reader_outbound {
7420 if let Some(secured) = protect_user_reader_datagram(&rt, &dg.bytes) {
7421 for t in dg.targets.iter() {
7422 if is_routable_user_locator(t) {
7423 let _ = rt.user_unicast.send(t, &secured);
7424 }
7425 }
7426 }
7427 }
7428
7429 // (User-data unicast recv: now in `recv_user_data_loop`.)
7430
7431 // --- Per-interface inbound ---
7432 //
7433 // Each pool binding is polled non-blocking; the
7434 // received datagram goes through `secure_inbound_bytes` with
7435 // the matching NetInterface class. This lets the
7436 // PolicyEngine make interface-specific decisions
7437 // (e.g. accept loopback-plain on a protected domain).
7438 //
7439 // The non-blocking semantics are achieved by each socket
7440 // in `bind_all` holding a short read timeout — see
7441 // `OutboundSocketPool::bind_all`. Without a timeout the
7442 // event loop would hang on an empty binding per tick.
7443 #[cfg(feature = "security")]
7444 if let Some(pool) = &rt.outbound_pool {
7445 for binding in &pool.bindings {
7446 while let Ok(dg) = binding.socket.recv() {
7447 let iface = binding.spec.kind.clone();
7448 if let Some(clear) = secure_inbound_bytes(&rt, &dg.data, &iface) {
7449 // Try SPDP first (reverse beacons), then
7450 // SEDP, then user data — same dispatch as
7451 // for the legacy sockets.
7452 handle_spdp_datagram(&rt, &clear);
7453 let events = rt
7454 .sedp
7455 .lock()
7456 .ok()
7457 .and_then(|mut s| s.handle_datagram(&clear, sedp_now).ok());
7458 if let Some(ev) = events {
7459 if !ev.is_empty() {
7460 run_matching_pass(&rt);
7461 push_sedp_events_to_builtin_readers(&rt, &ev);
7462 }
7463 }
7464 if !dispatch_type_lookup_datagram(&rt, &clear, &dg.source) {
7465 handle_user_datagram(&rt, &clear, sedp_now);
7466 }
7467 // DDS-Security 1.2 §7.4.2 Builtin-Endpoints
7468 for dg in dispatch_security_builtin_datagram(&rt, &clear, sedp_now) {
7469 send_discovery_datagram(&rt, &dg.targets, &dg.bytes);
7470 }
7471 }
7472 }
7473 }
7474 }
7475
7476 // Housekeeping (deadline/lifespan/liveliness) runs as a separate
7477 // `tick_housekeep` call of the respective driver (tick_loop /
7478 // tick_driver / scheduler_tick_loop) — see `tick_housekeep`.
7479
7480 // Diagnostic: mark this iteration complete so `tick_count()` advances
7481 // whether driven by the internal thread or an external executor.
7482 rt.tick_seq.fetch_add(1, Ordering::Relaxed);
7483}
7484
7485/// Min tracker for the earliest "next-due" instant (nanos in the runtime
7486/// `elapsed` time base) across multiple housekeeping sources.
7487struct NextDue(Option<u64>);
7488
7489impl NextDue {
7490 fn new() -> Self {
7491 Self(None)
7492 }
7493 fn note(&mut self, due_nanos: u64) {
7494 self.0 = Some(self.0.map_or(due_nanos, |e| e.min(due_nanos)));
7495 }
7496 fn into_inner(self) -> Option<u64> {
7497 self.0
7498 }
7499}
7500
7501/// D.5e Phase 3 B-2 — the time-driven housekeeping checks, factored out of
7502/// [`run_tick_iteration`], so the event-driven scheduler can fire them
7503/// as its own [`TickEvent::Housekeep`] heap event exactly at the next
7504/// due-instant (and `tick_loop`/`tick_driver` call them inline).
7505/// Pure reader/writer-side bookkeeping — **no** cross-vendor wire
7506/// output, the cadence is purely internal.
7507///
7508/// Return value: the earliest instant (nanos in the `elapsed` time base) at which
7509/// a check is due again, or `None` if nothing is currently pending
7510/// (no active deadline/lifespan/liveliness slot) — then the
7511/// scheduler parks until the idle floor resp. until a `raise_tick_wake` signals new
7512/// work.
7513fn tick_housekeep(rt: &Arc<DcpsRuntime>, elapsed: Duration) -> Option<u64> {
7514 let mut next_due = NextDue::new();
7515 // --- Deadline-Monitoring ---
7516 if let Some(d) = check_deadlines(rt, elapsed) {
7517 next_due.note(d);
7518 }
7519 // --- Lifespan-Expire ---
7520 if let Some(d) = expire_by_lifespan(rt, elapsed) {
7521 next_due.note(d);
7522 }
7523 // --- Liveliness lease check (reader side) ---
7524 if let Some(d) = check_liveliness(rt, elapsed) {
7525 next_due.note(d);
7526 }
7527 // --- Writer-side liveliness-lost check ---
7528 if let Some(d) = check_writer_liveliness(rt, elapsed) {
7529 next_due.note(d);
7530 }
7531 next_due.into_inner()
7532}
7533
7534impl DcpsRuntime {
7535 /// Number of completed tick iterations since `start()`. Advances once per
7536 /// tick regardless of whether the internal `zdds-tick` thread or an
7537 /// external executor ([`DcpsRuntime::tick_driver`]) drives it — a stalled
7538 /// value means the periodic tick stopped. Diagnostic only.
7539 #[must_use]
7540 pub fn tick_count(&self) -> u64 {
7541 self.tick_seq.load(Ordering::Relaxed)
7542 }
7543
7544 /// Number of SPDP announces emitted since `start()`. Diagnostic for the C3
7545 /// initial-announcement burst: a fresh participant with no discovered peer
7546 /// advances this at [`RuntimeConfig::initial_announce_period`] for the first
7547 /// [`RuntimeConfig::initial_announce_count`] announces, then slows to
7548 /// `spdp_period`.
7549 #[must_use]
7550 pub fn spdp_announce_count(&self) -> u64 {
7551 self.spdp_announce_seq.load(Ordering::Relaxed)
7552 }
7553
7554 /// Number of discovered topic inconsistencies (DDS 1.4 §2.2.4.2.4).
7555 /// Bumped during matching against the SEDP cache whenever a remote
7556 /// endpoint carries the same `topic_name` but a differing `type_name`
7557 /// than a local endpoint. A delta against the last poll snapshot
7558 /// triggers `on_inconsistent_topic`.
7559 #[must_use]
7560 pub fn inconsistent_topic_count(&self) -> u64 {
7561 self.inconsistent_topic_seq.load(Ordering::Relaxed)
7562 }
7563
7564 /// External tick driver (zerodds-async-1.0 §4). Only meaningful when the
7565 /// runtime was started with [`RuntimeConfig::external_tick`] = `true`,
7566 /// which suppresses the dedicated `zdds-tick` thread. Each
7567 /// [`DcpsTickDriver::tick`] call runs exactly one tick iteration; the
7568 /// caller schedules the next after [`DcpsTickDriver::tick_period`]. The
7569 /// async API's `spawn_in_tokio` uses this to multiplex many participants'
7570 /// tick loops onto a tokio runtime instead of one std::thread each.
7571 #[must_use]
7572 pub fn tick_driver(self: &Arc<Self>) -> DcpsTickDriver {
7573 DcpsTickDriver {
7574 st: TickState::new(self),
7575 rt: Arc::clone(self),
7576 }
7577 }
7578
7579 /// D.5e Phase 3 — wake the scheduler tick worker immediately (new work:
7580 /// a sample written, a HEARTBEAT/DATA/ACKNACK received). Coalesced: many
7581 /// raises between two worker passes collapse into a single wake, so a
7582 /// datagram storm does not flood the channel. No-op unless started with
7583 /// `scheduler_tick`.
7584 pub fn raise_tick_wake(&self) {
7585 // Only the first raiser since the last pass actually sends.
7586 if self.tick_wake_pending.swap(true, Ordering::AcqRel) {
7587 return;
7588 }
7589 if let Ok(guard) = self.tick_wake.lock() {
7590 if let Some(h) = guard.as_ref() {
7591 // Active traffic wakes the reliable tick AND re-evaluates
7592 // housekeeping, so a freshly-armed deadline/lifespan/liveliness
7593 // window is scheduled at once instead of waiting out the park.
7594 h.raise_now(TickEvent::Tick);
7595 h.raise_now(TickEvent::Housekeep);
7596 }
7597 }
7598 }
7599
7600 /// `true` if this participant has any user DataWriter or DataReader — i.e.
7601 /// the fine-grained periodic work (HEARTBEAT / ACKNACK / deadline / lifespan
7602 /// / liveliness) may be due and the scheduler keeps a fine cadence. A pure
7603 /// discovery-only participant parks long.
7604 fn has_user_endpoints(&self) -> bool {
7605 self.user_writers
7606 .read()
7607 .map(|m| !m.is_empty())
7608 .unwrap_or(true)
7609 || self
7610 .user_readers
7611 .read()
7612 .map(|m| !m.is_empty())
7613 .unwrap_or(true)
7614 }
7615}
7616
7617/// Drives a runtime's periodic tick from an external executor (tokio, an
7618/// embedded scheduler, a manual test loop). Obtained via
7619/// [`DcpsRuntime::tick_driver`]; only does useful work when the runtime was
7620/// started with [`RuntimeConfig::external_tick`] = `true`.
7621///
7622/// Typical loop (the async crate's `spawn_in_tokio` shape):
7623///
7624/// ```ignore
7625/// let mut driver = runtime.tick_driver();
7626/// let period = driver.tick_period();
7627/// while !driver.is_stopped() {
7628/// driver.tick();
7629/// tokio::time::sleep(period).await;
7630/// }
7631/// ```
7632pub struct DcpsTickDriver {
7633 rt: Arc<DcpsRuntime>,
7634 st: TickState,
7635}
7636
7637impl DcpsTickDriver {
7638 /// Period the caller should wait between consecutive [`Self::tick`] calls
7639 /// (mirrors the internal `zdds-tick` thread's `tick_period`).
7640 #[must_use]
7641 pub fn tick_period(&self) -> Duration {
7642 self.rt.config.tick_period
7643 }
7644
7645 /// `true` once the runtime is shutting down (set by `Drop`/`stop()`). The
7646 /// driving task must then stop calling [`Self::tick`] and return so the
7647 /// runtime can be dropped cleanly.
7648 #[must_use]
7649 pub fn is_stopped(&self) -> bool {
7650 self.rt.stop.load(Ordering::Relaxed)
7651 }
7652
7653 /// Run one tick iteration: periodic SPDP announce, SEDP/WLP ticks,
7654 /// per-user-writer ticks, deadline/lifespan/liveliness checks. Equivalent
7655 /// to one pass of the internal `zdds-tick` loop body.
7656 pub fn tick(&mut self) {
7657 run_tick_iteration(Arc::clone(&self.rt), &mut self.st);
7658 tick_housekeep(&self.rt, self.rt.start_instant.elapsed());
7659 }
7660}
7661
7662/// Writer-side liveliness-lost detection. Spec §2.2.4.2.10.
7663///
7664/// For all user writers: if a lease duration is set and more time
7665/// has elapsed since the last assert (Automatic = `last_write`, Manual =
7666/// `last_liveliness_assert`) than the
7667/// lease duration allows, the writer counts as
7668/// "not-alive" from the DDS view — `liveliness_lost_count++` and reset the window.
7669///
7670/// Note: with pure best-effort tests + `Automatic` the
7671/// counter typically does not advance — Automatic asserts with every
7672/// `write_user_sample`. Manual mode requires an explicit
7673/// `assert_liveliness` (comes with .4b — until then we already provide
7674/// the detection here, the hot-path trigger triggers it).
7675fn check_writer_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7676 let now_nanos = now.as_nanos() as u64;
7677 let mut next_due = NextDue::new();
7678 for (_eid, arc) in rt.writer_slots_snapshot() {
7679 let Ok(mut slot) = arc.lock() else { continue };
7680 if slot.liveliness_lease_nanos == 0 {
7681 continue;
7682 }
7683 let last = match slot.liveliness_kind {
7684 zerodds_qos::LivelinessKind::Automatic => slot.last_write,
7685 _ => slot.last_liveliness_assert,
7686 };
7687 let last_nanos = match last {
7688 Some(t) => t.as_nanos() as u64,
7689 None => continue,
7690 };
7691 if now_nanos.saturating_sub(last_nanos) >= slot.liveliness_lease_nanos {
7692 slot.liveliness_lost_count = slot.liveliness_lost_count.saturating_add(1);
7693 // Reset the window, so the same lease-window
7694 // overrun does not count in an infinite loop.
7695 // Spec §2.2.3.11: "lease has elapsed" — `>=` is boundary-
7696 // stable and avoids flakiness when tick_period == lease.
7697 slot.last_liveliness_assert = Some(now);
7698 slot.last_write = Some(now);
7699 next_due.note(now_nanos.saturating_add(slot.liveliness_lease_nanos));
7700 } else {
7701 next_due.note(last_nanos.saturating_add(slot.liveliness_lease_nanos));
7702 }
7703 }
7704 next_due.into_inner()
7705}
7706
7707/// Checks for all user readers whether the writer has delivered no sample
7708/// for longer than `lease_duration`. If so: transition
7709/// alive → not_alive, `not_alive_count++`.
7710///
7711/// Automatic liveliness (§2.2.3.11): every write is an implicit assert.
7712/// So we check the reader-side `last_sample_received`.
7713/// Manual kinds come with .4b (explicit assert messages).
7714fn check_liveliness(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7715 let now_nanos = now.as_nanos() as u64;
7716 let mut next_due = NextDue::new();
7717 for (_eid, arc) in rt.reader_slots_snapshot() {
7718 let Ok(mut slot) = arc.lock() else { continue };
7719 if slot.liveliness_lease_nanos == 0 {
7720 continue;
7721 }
7722 // Until the first sample: consider it alive (optimistic).
7723 let last = match slot.last_sample_received {
7724 Some(t) => t.as_nanos() as u64,
7725 None => continue,
7726 };
7727 // Only a still-alive reader can transition; one already
7728 // not_alive stays so until a new sample arrives (event-driven
7729 // via the recv path) — so no re-schedule needed.
7730 if !slot.liveliness_alive {
7731 continue;
7732 }
7733 if now_nanos.saturating_sub(last) >= slot.liveliness_lease_nanos {
7734 slot.liveliness_alive = false;
7735 slot.liveliness_not_alive_count = slot.liveliness_not_alive_count.saturating_add(1);
7736 } else {
7737 next_due.note(last.saturating_add(slot.liveliness_lease_nanos));
7738 }
7739 }
7740 next_due.into_inner()
7741}
7742
7743/// For all user writers: remove samples from the HistoryCache whose
7744/// insert time + lifespan has elapsed. OMG DDS 1.4 §2.2.3.16:
7745/// "If the duration...elapses and the sample is still in the cache...
7746/// the sample is no longer available to any future DataReaders".
7747///
7748/// Implementation: `sample_insert_times` is a VecDeque, sorted
7749/// by insert time (= SN, because monotonic). Front-pop while expired;
7750/// the highest expired SN runs through via `cache.remove_up_to(sn + 1)`.
7751fn expire_by_lifespan(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7752 let now_nanos = now.as_nanos() as u64;
7753 let mut next_due = NextDue::new();
7754 for (_eid, arc) in rt.writer_slots_snapshot() {
7755 let Ok(mut slot) = arc.lock() else { continue };
7756 if slot.lifespan_nanos == 0 {
7757 continue;
7758 }
7759 let mut highest_expired = None;
7760 while let Some(&(sn, inserted)) = slot.sample_insert_times.front() {
7761 let inserted_nanos = inserted.as_nanos() as u64;
7762 if now_nanos.saturating_sub(inserted_nanos) >= slot.lifespan_nanos {
7763 highest_expired = Some(sn);
7764 slot.sample_insert_times.pop_front();
7765 } else {
7766 break;
7767 }
7768 }
7769 if let Some(sn) = highest_expired {
7770 let _removed = slot
7771 .writer
7772 .remove_samples_up_to(zerodds_rtps::wire_types::SequenceNumber(sn.0 + 1));
7773 }
7774 // Next lifespan due = expiry of the now-oldest sample still
7775 // remaining in the cache. Empty deque → nothing due,
7776 // until a new sample is written (raise_tick_wake covers that).
7777 if let Some(&(_sn, inserted)) = slot.sample_insert_times.front() {
7778 next_due.note((inserted.as_nanos() as u64).saturating_add(slot.lifespan_nanos));
7779 }
7780 }
7781 next_due.into_inner()
7782}
7783
7784/// Checks for all user writers + user readers whether the deadline period
7785/// has been exceeded since the last sample. Every exceedance
7786/// increments the corresponding missed counter by exactly 1
7787/// — regardless of how often `check_deadlines` is called within an
7788/// elapsed window, because we keep setting `last_*`
7789/// to "now" after we have counted.
7790///
7791/// **Init-state semantics:** as long as `last_write`/`last_sample_received`
7792/// is `None` (no real write/sample yet), the deadline
7793/// check does not count. Only after the first real data point does the
7794/// deadline window start. This prevents false misses due to slow
7795/// entity setup (Linux CI/container) before the app even issues a
7796/// write.
7797fn check_deadlines(rt: &Arc<DcpsRuntime>, now: std::time::Duration) -> Option<u64> {
7798 let now_nanos = now.as_nanos() as u64;
7799 let mut next_due = NextDue::new();
7800 for (_eid, arc) in rt.writer_slots_snapshot() {
7801 let Ok(mut slot) = arc.lock() else { continue };
7802 if slot.deadline_nanos == 0 {
7803 continue;
7804 }
7805 let Some(last) = slot.last_write.map(|d| d.as_nanos() as u64) else {
7806 // Never written yet → deadline window not active.
7807 continue;
7808 };
7809 if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
7810 slot.offered_deadline_missed_count =
7811 slot.offered_deadline_missed_count.saturating_add(1);
7812 // Reset the window: the next deadline is counted relative
7813 // to the current tick. `>=` is boundary-stable
7814 // (Spec §2.2.3.7: "deadline has elapsed").
7815 slot.last_write = Some(now);
7816 next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
7817 } else {
7818 next_due.note(last.saturating_add(slot.deadline_nanos));
7819 }
7820 }
7821 for (_eid, arc) in rt.reader_slots_snapshot() {
7822 let Ok(mut slot) = arc.lock() else { continue };
7823 if slot.deadline_nanos == 0 {
7824 continue;
7825 }
7826 let Some(last) = slot.last_sample_received.map(|d| d.as_nanos() as u64) else {
7827 continue;
7828 };
7829 if now_nanos.saturating_sub(last) >= slot.deadline_nanos {
7830 slot.requested_deadline_missed_count =
7831 slot.requested_deadline_missed_count.saturating_add(1);
7832 slot.last_sample_received = Some(now);
7833 next_due.note(now_nanos.saturating_add(slot.deadline_nanos));
7834 } else {
7835 next_due.note(last.saturating_add(slot.deadline_nanos));
7836 }
7837 }
7838 next_due.into_inner()
7839}
7840
7841/// For all local writers + readers: matching against the current
7842/// SEDP cache. A cheap re-run when SEDP events came in — idempotent,
7843/// because ReliableWriter/Reader add_*_proxy are idempotent (same
7844/// GUID → replaced).
7845fn run_matching_pass(rt: &Arc<DcpsRuntime>) {
7846 let writer_ids: Vec<EntityId> = rt.writer_eids();
7847 for eid in writer_ids {
7848 rt.match_local_writer_against_cache(eid);
7849 }
7850 let reader_ids: Vec<EntityId> = rt.reader_eids();
7851 for eid in reader_ids {
7852 rt.match_local_reader_against_cache(eid);
7853 }
7854}
7855
7856/// Returns the default-unicast locator of a discovered remote
7857/// participant.
7858fn remote_user_locators(
7859 prefix: GuidPrefix,
7860 discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
7861) -> Vec<Locator> {
7862 match discovered.lock() {
7863 Ok(cache) => cache
7864 .get(&prefix)
7865 .and_then(|p| p.data.default_unicast_locator)
7866 .into_iter()
7867 .collect(),
7868 Err(_) => Vec::new(),
7869 }
7870}
7871
7872/// Determine the destination for user traffic to a remote endpoint.
7873///
7874/// DDSI-RTPS 2.5 §8.5.3.2/§8.5.3.3: the per-endpoint `unicastLocatorList`
7875/// from the SEDP announce is authoritative. §8.5.5: only when it is empty
7876/// does the sender fall back to the participant `DEFAULT_UNICAST_LOCATOR` from
7877/// SPDP.
7878///
7879/// Before this fix ZeroDDS *always* used the participant default — which
7880/// broke OpenDDS interop: OpenDDS stores only the
7881/// placeholder 127.0.0.1:12345 as the participant default and announces the real user locator
7882/// exclusively per-endpoint.
7883fn endpoint_or_default_locators(
7884 endpoint: &[Locator],
7885 prefix: GuidPrefix,
7886 discovered: &Arc<Mutex<DiscoveredParticipantsCache>>,
7887) -> Vec<Locator> {
7888 if !endpoint.is_empty() {
7889 return endpoint.to_vec();
7890 }
7891 remote_user_locators(prefix, discovered)
7892}
7893
7894/// Dispatches a received RTPS datagram to matching user readers.
7895/// Decides, based on the `reader_id` in DATA/DATA_FRAG/HEARTBEAT/GAP,
7896/// which local reader is responsible.
7897/// Strip the 4-byte encapsulation header off the received sample payload.
7898/// Returns `None` if the payload is < 4 bytes or carries an unknown
7899/// scheme (PL_CDR variants would not get here; they go via
7900/// SEDP — if we see such a thing on user endpoints, it is garbage).
7901/// Spec §3.2 zerodds-async-1.0: wakes a registered waker
7902/// after every `sample_tx.send`. `take` consumes the waker, to
7903/// avoid double wakeups — the caller registers a new one after
7904/// every `Pending` result.
7905fn wake_async_waker(slot: &alloc::sync::Arc<std::sync::Mutex<Option<core::task::Waker>>>) {
7906 if let Ok(mut g) = slot.lock() {
7907 if let Some(w) = g.take() {
7908 w.wake();
7909 }
7910 }
7911}
7912
7913/// Converts a sample delivered by the ReliableReader into a
7914/// `UserSample` channel entry. For `ChangeKind::Alive` the
7915/// CDR encapsulation header is stripped; for lifecycle markers
7916/// the key hash is reconstructed from the bytes.
7917/// Inspect-endpoint tap dispatch for the DCPS receive path.
7918///
7919/// Called in `handle_user_datagram` when a sample is delivered to
7920/// a user reader. Only when the `inspect` feature is
7921/// on; without the feature no code, no branch.
7922#[cfg(feature = "inspect")]
7923fn dispatch_inspect_dcps_receive_tap(topic: &str, reader_id: EntityId, item: &UserSample) {
7924 let payload: Vec<u8> = match item {
7925 UserSample::Alive { payload, .. } => payload.to_vec(),
7926 UserSample::Lifecycle { key_hash, .. } => key_hash.to_vec(),
7927 };
7928 let ts_ns = std::time::SystemTime::now()
7929 .duration_since(std::time::UNIX_EPOCH)
7930 .map(|d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
7931 .unwrap_or(0);
7932 let mut corr: u64 = 0;
7933 for (i, byte) in reader_id.entity_key.iter().enumerate() {
7934 corr |= u64::from(*byte) << (i * 8);
7935 }
7936 corr |= u64::from(reader_id.entity_kind as u8) << 24;
7937 let frame = zerodds_inspect_endpoint::Frame::dcps(topic.to_owned(), ts_ns, corr, payload);
7938 zerodds_inspect_endpoint::tap::dispatch(&frame);
7939}
7940
7941fn delivered_to_user_sample(
7942 sample: &zerodds_rtps::reliable_reader::DeliveredSample,
7943 writer_strengths: &alloc::collections::BTreeMap<[u8; 16], i32>,
7944) -> Option<UserSample> {
7945 use zerodds_rtps::history_cache::ChangeKind;
7946 match sample.kind {
7947 ChangeKind::Alive | ChangeKind::AliveFiltered => {
7948 let writer_guid = sample.writer_guid.to_bytes();
7949 let writer_strength = writer_strengths.get(&writer_guid).copied().unwrap_or(0);
7950 // Encapsulation representation from byte[1] of the header
7951 // (RTPS 2.5 §10.5) — BEFORE stripping. 0x00–0x03 = XCDR1
7952 // (CDR/PL_CDR), 0x06–0x0b = XCDR2 (CDR2/D_CDR2/PL_CDR2).
7953 let representation = encap_representation(&sample.payload);
7954 strip_user_encap_arc(&sample.payload).map(|payload| UserSample::Alive {
7955 payload,
7956 writer_guid,
7957 writer_strength,
7958 representation,
7959 })
7960 }
7961 ChangeKind::NotAliveDisposed
7962 | ChangeKind::NotAliveUnregistered
7963 | ChangeKind::NotAliveDisposedUnregistered => {
7964 // Lifecycle marker: Spec §9.6.4.8 + §9.6.3.9 requires
7965 // `PID_KEY_HASH` in the inline QoS — the reader reads it
7966 // and propagates it via `DeliveredSample.key_hash`.
7967 // Fallback: with non-spec-conformant writers the
7968 // hash falls back to the first 16 bytes of the key-only payload
7969 // (PLAIN_CDR2-BE key holder).
7970 let kh = sample.key_hash.unwrap_or_else(|| {
7971 let mut h = [0u8; 16];
7972 let n = sample.payload.len().min(16);
7973 h[..n].copy_from_slice(&sample.payload[..n]);
7974 h
7975 });
7976 Some(UserSample::Lifecycle {
7977 key_hash: kh,
7978 kind: sample.kind,
7979 })
7980 }
7981 }
7982}
7983
7984/// Returns the XCDR version from the 4-byte encapsulation header
7985/// (RTPS 2.5 §10.5): `0` = XCDR1 (CDR/PL_CDR, encap byte 0x00–0x05),
7986/// `1` = XCDR2 (CDR2/DELIMITED_CDR2/PL_CDR2, encap byte 0x06–0x0b).
7987/// Default `0` for a too-short payload — XCDR1 is the spec baseline.
7988fn encap_representation(payload: &[u8]) -> u8 {
7989 if payload.len() >= 2 && payload[1] >= 0x06 {
7990 1
7991 } else {
7992 0
7993 }
7994}
7995
7996/// Checks whether `payload` has a known 4-byte encapsulation header.
7997/// Returns `Some(4)` if so (= offset behind the header), `None` if
7998/// no known scheme. Separated in use from [`strip_user_encap`]:
7999/// here only validation without allocation, for the listener zero-copy
8000/// path (lever E / Sprint D.5d).
8001fn validate_user_encap_offset(payload: &[u8]) -> Option<usize> {
8002 if payload.len() < 4 {
8003 return None;
8004 }
8005 // Accept all data-representation schemes (RTPS 2.5 §10.5,
8006 // table 10.3): byte0 = 0x00, byte1 in:
8007 // 0x00/0x01 CDR_BE/LE (XCDR1 PLAIN_CDR)
8008 // 0x02/0x03 PL_CDR_BE/LE (XCDR1 parameter list, key serial.)
8009 // 0x06/0x07 CDR2_BE/LE (XCDR2 PLAIN_CDR2)
8010 // 0x08/0x09 D_CDR2_BE/LE (XCDR2 DELIMITED_CDR2, @appendable)
8011 // 0x0a/0x0b PL_CDR2_BE/LE (XCDR2 PL_CDR2, @mutable)
8012 // Cyclone often sends XCDR1, OpenDDS/FastDDS XCDR2. We pass
8013 // all through; the typed decoder picks the correct alignment rule
8014 // based on the `representation` (see `encap_representation`).
8015 if payload[0] != 0x00 {
8016 return None;
8017 }
8018 match payload[1] {
8019 0x00..=0x03 | 0x06..=0x0b => Some(4),
8020 _ => None,
8021 }
8022}
8023
8024/// Zero-copy variant: strips the encap header via range slicing
8025/// on the refcounted `Arc<[u8]>` backing store. No heap alloc.
8026/// Spec: `docs/specs/zerodds-zero-copy-1.0.md` §6 wave 2.
8027fn strip_user_encap_arc(
8028 payload: &alloc::sync::Arc<[u8]>,
8029) -> Option<crate::sample_bytes::SampleBytes> {
8030 validate_user_encap_offset(payload).map(|off| {
8031 crate::sample_bytes::SampleBytes::from_arc_slice(
8032 alloc::sync::Arc::clone(payload),
8033 off..payload.len(),
8034 )
8035 })
8036}
8037
8038#[cfg(test)]
8039fn strip_user_encap(payload: &[u8]) -> Option<alloc::vec::Vec<u8>> {
8040 validate_user_encap_offset(payload).map(|off| payload[off..].to_vec())
8041}
8042
8043/// Bench-only phase-timing accumulators. Active with env
8044/// `ZERODDS_PHASE_TIMING=1`. With `ZERODDS_PHASE_DUMP=1` the
8045/// atexit hook prints the totals on drop of the first runtime.
8046#[doc(hidden)]
8047pub static PHASE_HANDLE_USER_NS: core::sync::atomic::AtomicU64 =
8048 core::sync::atomic::AtomicU64::new(0);
8049#[doc(hidden)]
8050pub static PHASE_HANDLE_USER_CALLS: core::sync::atomic::AtomicU64 =
8051 core::sync::atomic::AtomicU64::new(0);
8052#[doc(hidden)]
8053pub static PHASE_WRITE_USER_NS: core::sync::atomic::AtomicU64 =
8054 core::sync::atomic::AtomicU64::new(0);
8055#[doc(hidden)]
8056pub static PHASE_WRITE_USER_CALLS: core::sync::atomic::AtomicU64 =
8057 core::sync::atomic::AtomicU64::new(0);
8058
8059/// Sub-phases in the `handle_user_datagram` receive hot path:
8060/// 0=decode_datagram, 1=slot-lookup+lock, 2=reader.handle_data,
8061/// 3=delivered_to_user_sample, 4=listener+sender-dispatch.
8062/// Active under `ZERODDS_PHASE_TIMING=1`. Each `Instant::now()` bracket
8063/// costs ~50 ns; at a ~3 µs handle that is ~1.6% per sub-phase.
8064#[doc(hidden)]
8065pub static PHASE_HANDLE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8066 core::sync::atomic::AtomicU64::new(0),
8067 core::sync::atomic::AtomicU64::new(0),
8068 core::sync::atomic::AtomicU64::new(0),
8069 core::sync::atomic::AtomicU64::new(0),
8070 core::sync::atomic::AtomicU64::new(0),
8071];
8072
8073/// Sub-phases in `write_user_sample_borrowed` (sender hot path):
8074/// 0=lookup, 1=lock, 2=write_with_heartbeat, 3=send-loop, 4=reserved.
8075/// The detail drilldown into socket.send_to vs. inproc-peer dispatch was
8076/// done once for the connected-UDP lever (showed send_to as
8077/// 97% of the dispatch path); not permanent in the code, because per-phase
8078/// `Instant::now()` itself costs ~50 ns — at a 6 µs send that
8079/// would be 1% overhead and skews the calibrated measurement.
8080#[doc(hidden)]
8081pub static PHASE_WRITE_SUB_NS: [core::sync::atomic::AtomicU64; 5] = [
8082 core::sync::atomic::AtomicU64::new(0),
8083 core::sync::atomic::AtomicU64::new(0),
8084 core::sync::atomic::AtomicU64::new(0),
8085 core::sync::atomic::AtomicU64::new(0),
8086 core::sync::atomic::AtomicU64::new(0),
8087];
8088
8089fn phase_timing_enabled() -> bool {
8090 static CACHE: core::sync::atomic::AtomicI8 = core::sync::atomic::AtomicI8::new(-1);
8091 let v = CACHE.load(core::sync::atomic::Ordering::Relaxed);
8092 if v >= 0 {
8093 return v == 1;
8094 }
8095 let on = std::env::var("ZERODDS_PHASE_TIMING")
8096 .map(|s| s == "1")
8097 .unwrap_or(false);
8098 CACHE.store(
8099 if on { 1 } else { 0 },
8100 core::sync::atomic::Ordering::Relaxed,
8101 );
8102 on
8103}
8104
8105struct PhaseTimer {
8106 start: std::time::Instant,
8107 ns_acc: &'static core::sync::atomic::AtomicU64,
8108 calls_acc: &'static core::sync::atomic::AtomicU64,
8109}
8110
8111impl Drop for PhaseTimer {
8112 fn drop(&mut self) {
8113 let ns = self.start.elapsed().as_nanos() as u64;
8114 self.ns_acc
8115 .fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8116 self.calls_acc
8117 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
8118 }
8119}
8120
8121fn handle_user_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], now: Duration) {
8122 let _phase_guard = if phase_timing_enabled() {
8123 Some(PhaseTimer {
8124 start: std::time::Instant::now(),
8125 ns_acc: &PHASE_HANDLE_USER_NS,
8126 calls_acc: &PHASE_HANDLE_USER_CALLS,
8127 })
8128 } else {
8129 None
8130 };
8131 let pt_on = phase_timing_enabled();
8132 let pt_t0 = if pt_on {
8133 Some(std::time::Instant::now())
8134 } else {
8135 None
8136 };
8137 let parsed = match decode_datagram(bytes) {
8138 Ok(p) => p,
8139 Err(_) => return,
8140 };
8141 // DDSI-RTPS §8.3.4: the effective source of each writer submessage is the
8142 // sourceGuidPrefix from the RTPS header. The reader demux needs it to
8143 // distinguish writer proxies with the same EntityId but a different participant
8144 // (fan-in / multiple publishers on the same topic).
8145 let src_prefix = parsed.header.guid_prefix;
8146 if let (Some(t0), true) = (pt_t0, pt_on) {
8147 let ns = t0.elapsed().as_nanos() as u64;
8148 PHASE_HANDLE_SUB_NS[0].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8149 }
8150 // Per-submessage: take the matching slot mutex individually per
8151 // submessage — no global user_writers/user_readers lock anymore.
8152 // With per-submessage granularity, reader datagrams can be processed in parallel
8153 // to writer AckNacks.
8154 for sub in parsed.submessages {
8155 match sub {
8156 ParsedSubmessage::Data(d) => {
8157 // Sprint D.5d lever B — collect-then-dispatch:
8158 // sample conversion + liveliness update inside slot.lock,
8159 // then listener fire + channel send + waker wake
8160 // OUTSIDE the lock.
8161 //
8162 // Cross-vendor fix 2026-05-19: when reader_id ==
8163 // ENTITYID_UNKNOWN (RTPS spec §8.3.7.2: "deliver to all
8164 // matched readers on this topic"), we iterate over
8165 // ALL reader slots and let `handle_data` filter by
8166 // writer_proxies. Cyclone DDS/FastDDS/RTI send
8167 // user DATA with reader_id=UNKNOWN; without this fan-out
8168 // ZeroDDS would drop every such DATA.
8169 let pt_t1 = if pt_on {
8170 Some(std::time::Instant::now())
8171 } else {
8172 None
8173 };
8174 let target_slots: Vec<ReaderSlotArc> = if d.reader_id == EntityId::UNKNOWN {
8175 let snap = rt.reader_slots_snapshot();
8176 let mut v = Vec::with_capacity(snap.len());
8177 v.extend(snap.into_iter().map(|(_, arc)| arc));
8178 v
8179 } else {
8180 let mut v = Vec::with_capacity(1);
8181 if let Some(arc) = rt.reader_slot(d.reader_id) {
8182 v.push(arc);
8183 }
8184 v
8185 };
8186 if let (Some(t1), true) = (pt_t1, pt_on) {
8187 let ns = t1.elapsed().as_nanos() as u64;
8188 PHASE_HANDLE_SUB_NS[1].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8189 }
8190 for arc in target_slots {
8191 // Lever E: alongside the UserSample we carry a
8192 // zero-copy view on the original `Arc<[u8]>` with
8193 // the encap offset — the listener can thereby read into
8194 // the payload without allocation.
8195 let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
8196 let listener;
8197 let waker;
8198 let sender;
8199 #[cfg(feature = "inspect")]
8200 let topic_name;
8201 let pt_t2 = if pt_on {
8202 Some(std::time::Instant::now())
8203 } else {
8204 None
8205 };
8206 {
8207 let Ok(mut slot) = arc.lock() else { continue };
8208 let hd_samples: Vec<_> = slot
8209 .reader
8210 .handle_data(src_prefix, &d)
8211 .into_iter()
8212 .collect();
8213 for sample in hd_samples {
8214 // Listener zero-copy view only for alive samples
8215 // with a valid encap header. Arc::clone is
8216 // an atomic refcount inc, no data copy.
8217 let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
8218 zerodds_rtps::history_cache::ChangeKind::Alive
8219 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
8220 validate_user_encap_offset(&sample.payload)
8221 .map(|off| (Arc::clone(&sample.payload), off))
8222 }
8223 _ => None,
8224 };
8225 if let Some(item) =
8226 delivered_to_user_sample(&sample, &slot.writer_strengths)
8227 {
8228 items.push((item, listener_view));
8229 }
8230 }
8231 if !items.is_empty() {
8232 slot.last_sample_received = Some(now);
8233 slot.samples_delivered_count = slot
8234 .samples_delivered_count
8235 .saturating_add(items.len() as u64);
8236 if !slot.liveliness_alive {
8237 slot.liveliness_alive = true;
8238 slot.liveliness_alive_count =
8239 slot.liveliness_alive_count.saturating_add(1);
8240 }
8241 }
8242 listener = slot.listener.clone();
8243 waker = Arc::clone(&slot.async_waker);
8244 sender = slot.sample_tx.clone();
8245 #[cfg(feature = "inspect")]
8246 {
8247 topic_name = slot.topic_name.clone();
8248 }
8249 }
8250 if let (Some(t2), true) = (pt_t2, pt_on) {
8251 let ns = t2.elapsed().as_nanos() as u64;
8252 PHASE_HANDLE_SUB_NS[2].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8253 }
8254 let pt_t3 = if pt_on {
8255 Some(std::time::Instant::now())
8256 } else {
8257 None
8258 };
8259 // --- Outside slot.lock: dispatch ---
8260 //
8261 // Listener and MPSC are exclusive: if a listener
8262 // (callback) is set, the consumer is on the
8263 // callback path — the additional `sender.send` +
8264 // `wake_async_waker` would be pure overhead AND
8265 // would grow the channel buffer unboundedly
8266 // (memory leak in callback-only apps). We
8267 // dispatch either the callback OR the MPSC, not
8268 // both. A caller (Rust API) that wants take()+listener
8269 // at the same time simply sets NO listener
8270 // and polls via take().
8271 for (item, listener_view) in items {
8272 let item_repr = if let UserSample::Alive { representation, .. } = &item {
8273 *representation
8274 } else {
8275 0
8276 };
8277 #[cfg(feature = "inspect")]
8278 dispatch_inspect_dcps_receive_tap(&topic_name, d.reader_id, &item);
8279 if let Some(ref l) = listener {
8280 if let Some((arc_payload, off)) = listener_view {
8281 // Zero-copy: slice view into the original Arc.
8282 l(&arc_payload[off..], item_repr);
8283 }
8284 } else {
8285 let _ = sender.send(item);
8286 wake_async_waker(&waker);
8287 }
8288 }
8289 if let (Some(t3), true) = (pt_t3, pt_on) {
8290 let ns = t3.elapsed().as_nanos() as u64;
8291 PHASE_HANDLE_SUB_NS[4].fetch_add(ns, core::sync::atomic::Ordering::Relaxed);
8292 }
8293 } // for arc in target_slots
8294 }
8295 ParsedSubmessage::DataFrag(df) => {
8296 // Lever B+E — see the Data arm above.
8297 // Cross-vendor: same UNKNOWN fan-out as for Data.
8298 let target_slots: Vec<ReaderSlotArc> = if df.reader_id == EntityId::UNKNOWN {
8299 rt.reader_slots_snapshot()
8300 .into_iter()
8301 .map(|(_, arc)| arc)
8302 .collect()
8303 } else {
8304 rt.reader_slot(df.reader_id).into_iter().collect()
8305 };
8306 for arc in target_slots {
8307 let mut items: Vec<UserSampleWithEncap> = Vec::with_capacity(4);
8308 let listener;
8309 let waker;
8310 let sender;
8311 #[cfg(feature = "inspect")]
8312 let topic_name;
8313 {
8314 let Ok(mut slot) = arc.lock() else { continue };
8315 for sample in slot.reader.handle_data_frag(src_prefix, &df, now) {
8316 let listener_view: Option<(Arc<[u8]>, usize)> = match sample.kind {
8317 zerodds_rtps::history_cache::ChangeKind::Alive
8318 | zerodds_rtps::history_cache::ChangeKind::AliveFiltered => {
8319 validate_user_encap_offset(&sample.payload)
8320 .map(|off| (Arc::clone(&sample.payload), off))
8321 }
8322 _ => None,
8323 };
8324 if let Some(item) =
8325 delivered_to_user_sample(&sample, &slot.writer_strengths)
8326 {
8327 items.push((item, listener_view));
8328 }
8329 }
8330 if !items.is_empty() {
8331 slot.last_sample_received = Some(now);
8332 slot.samples_delivered_count = slot
8333 .samples_delivered_count
8334 .saturating_add(items.len() as u64);
8335 if !slot.liveliness_alive {
8336 slot.liveliness_alive = true;
8337 slot.liveliness_alive_count =
8338 slot.liveliness_alive_count.saturating_add(1);
8339 }
8340 }
8341 listener = slot.listener.clone();
8342 waker = Arc::clone(&slot.async_waker);
8343 sender = slot.sample_tx.clone();
8344 #[cfg(feature = "inspect")]
8345 {
8346 topic_name = slot.topic_name.clone();
8347 }
8348 }
8349 for (item, listener_view) in items {
8350 let item_repr = if let UserSample::Alive { representation, .. } = &item {
8351 *representation
8352 } else {
8353 0
8354 };
8355 #[cfg(feature = "inspect")]
8356 dispatch_inspect_dcps_receive_tap(&topic_name, df.reader_id, &item);
8357 // See the Data arm: listener and MPSC are exclusive.
8358 if let Some(ref l) = listener {
8359 if let Some((arc_payload, off)) = listener_view {
8360 l(&arc_payload[off..], item_repr);
8361 }
8362 } else {
8363 let _ = sender.send(item);
8364 wake_async_waker(&waker);
8365 }
8366 }
8367 } // for arc in target_slots (DataFrag)
8368 }
8369 ParsedSubmessage::Heartbeat(h) => {
8370 // Lever B — collect-then-dispatch like the Data arm. An HB can
8371 // unlock samples that were waiting on a hole fill
8372 // (volatile skip, historic eviction).
8373 //
8374 // D.5e Phase-2: synchronous ACKNACK emit on HB receipt
8375 // instead of deferred-via-tick. With `heartbeat_response_delay=0`
8376 // (D.5e default) `tick_outbound(now)` flushes the
8377 // ACKNACK directly for all pending writer_proxies — the tick loop
8378 // no longer has to wait 5 ms.
8379 // Cross-vendor: a HEARTBEAT with reader_id=UNKNOWN is
8380 // "to all matched readers". Cyclone often packs this into
8381 // DATA+HB submessage bundles.
8382 let target_slots: Vec<ReaderSlotArc> = if h.reader_id == EntityId::UNKNOWN {
8383 rt.reader_slots_snapshot()
8384 .into_iter()
8385 .map(|(_, arc)| arc)
8386 .collect()
8387 } else {
8388 rt.reader_slot(h.reader_id).into_iter().collect()
8389 };
8390 for arc in target_slots {
8391 let mut items: Vec<UserSample> = Vec::new();
8392 let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
8393 Vec::new();
8394 let waker;
8395 let sender;
8396 {
8397 let Ok(mut slot) = arc.lock() else { continue };
8398 for sample in slot.reader.handle_heartbeat(src_prefix, &h, now) {
8399 if let Some(item) =
8400 delivered_to_user_sample(&sample, &slot.writer_strengths)
8401 {
8402 items.push(item);
8403 }
8404 }
8405 if !items.is_empty() {
8406 slot.last_sample_received = Some(now);
8407 slot.samples_delivered_count = slot
8408 .samples_delivered_count
8409 .saturating_add(items.len() as u64);
8410 if !slot.liveliness_alive {
8411 slot.liveliness_alive = true;
8412 slot.liveliness_alive_count =
8413 slot.liveliness_alive_count.saturating_add(1);
8414 }
8415 }
8416 // D.5e Phase-2: synchronous ACKNACK directly in the recv thread.
8417 if let Ok(dgs) = slot.reader.tick_outbound(now) {
8418 sync_outbound = dgs;
8419 }
8420 waker = Arc::clone(&slot.async_waker);
8421 sender = slot.sample_tx.clone();
8422 }
8423 for item in items {
8424 let _ = sender.send(item);
8425 wake_async_waker(&waker);
8426 }
8427 // Send ACKNACK datagrams synchronously — no tick-quantization tax.
8428 for dg in sync_outbound {
8429 if let Some(secured) = protect_user_reader_datagram(rt, &dg.bytes) {
8430 for t in dg.targets.iter() {
8431 if is_routable_user_locator(t) {
8432 let _ = rt.user_unicast.send(t, &secured);
8433 }
8434 }
8435 }
8436 }
8437 } // for arc in target_slots (Heartbeat)
8438 }
8439 ParsedSubmessage::Gap(g) => {
8440 // Cross-vendor: Gap with UNKNOWN reader → fan-out.
8441 let target_slots: Vec<ReaderSlotArc> = if g.reader_id == EntityId::UNKNOWN {
8442 rt.reader_slots_snapshot()
8443 .into_iter()
8444 .map(|(_, arc)| arc)
8445 .collect()
8446 } else {
8447 rt.reader_slot(g.reader_id).into_iter().collect()
8448 };
8449 for arc in target_slots {
8450 if let Ok(mut slot) = arc.lock() {
8451 for sample in slot.reader.handle_gap(src_prefix, &g) {
8452 if let Some(item) =
8453 delivered_to_user_sample(&sample, &slot.writer_strengths)
8454 {
8455 let _ = slot.sample_tx.send(item);
8456 wake_async_waker(&slot.async_waker);
8457 }
8458 }
8459 }
8460 }
8461 }
8462 ParsedSubmessage::AckNack(ack) => {
8463 if let Some(arc) = rt.writer_slot(ack.writer_id) {
8464 let mut sync_outbound: Vec<zerodds_rtps::message_builder::OutboundDatagram> =
8465 Vec::new();
8466 if let Ok(mut slot) = arc.lock() {
8467 let base = ack.reader_sn_state.bitmap_base;
8468 let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
8469 let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
8470 slot.writer.handle_acknack(src, base, requested);
8471 // D.5e Phase-2: synchronous resend on NACK receipt.
8472 // An ACKNACK may have listed requested SNs for resend;
8473 // tick delivers the resend datagrams directly in the recv thread.
8474 if let Ok(dgs) = slot.writer.tick(now) {
8475 sync_outbound = dgs;
8476 }
8477 }
8478 // ACK-Event-Cvar: wake `wait_for_acknowledgments`-waiters.
8479 rt.notify_ack_event();
8480 // Send sync resends (no more tick wait). FU2 S3:
8481 // per-target data_protection (a reliable resend of user DATA
8482 // must be encrypted just like the immediate send).
8483 for dg in sync_outbound {
8484 for t in dg.targets.iter() {
8485 if is_routable_user_locator(t) {
8486 if let Some(secured) =
8487 secure_outbound_for_target(rt, ack.writer_id, &dg.bytes, t)
8488 {
8489 let _ = rt.user_unicast.send(t, &secured);
8490 }
8491 }
8492 }
8493 }
8494 }
8495 }
8496 ParsedSubmessage::NackFrag(nf) => {
8497 if let Some(arc) = rt.writer_slot(nf.writer_id) {
8498 if let Ok(mut slot) = arc.lock() {
8499 let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
8500 slot.writer.handle_nackfrag(src, &nf);
8501 }
8502 }
8503 }
8504 _ => {}
8505 }
8506 }
8507}
8508
8509/// Test hook: allows a direct call of `handle_spdp_datagram` from
8510/// other modules without spinning up the whole event loop.
8511/// For internal tests only.
8512#[cfg(test)]
8513pub(crate) fn handle_spdp_datagram_for_test(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
8514 handle_spdp_datagram(rt, bytes);
8515}
8516
8517fn handle_spdp_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8]) {
8518 let parsed = match rt.spdp_reader.parse_datagram(bytes) {
8519 Ok(p) => p,
8520 Err(_) => return, // not SPDP or wire error — swallow
8521 };
8522 // Self-discovery filter: ignore our own beacons.
8523 if parsed.sender_prefix == rt.guid_prefix {
8524 return;
8525 }
8526 let is_new = {
8527 if let Ok(mut cache) = rt.discovered.lock() {
8528 cache.insert(parsed.clone())
8529 } else {
8530 false
8531 }
8532 };
8533 // On first discovery: wire the SEDP stack + send out initial
8534 // announcements.
8535 if is_new {
8536 if let Ok(mut sedp) = rt.sedp.lock() {
8537 sedp.on_participant_discovered(&parsed);
8538 }
8539 // Event-driven directed SPDP response (§8.5.3): send OUR own
8540 // SPDP IMMEDIATELY unicast to the newly discovered peer, instead of letting it
8541 // wait for our next periodic multicast beacon (spdp_period=5s, codepit-LXC
8542 // multicast flaky). A spec-conformant peer (OpenDDS)
8543 // processes our auth request ONLY once it has our identity_token from
8544 // our SPDP — without this directed response it waits up to
8545 // spdp_period (seconds latency → cross-vendor ping wait_for_matched
8546 // timeout). NO timeout band-aid: the seconds latency was the missing
8547 // discovery event. Token-less first beacons (security not yet enabled)
8548 // are NOT sent (see security_pending in the announce loop) — the
8549 // periodic/announce_spdp_now path catches up.
8550 #[cfg(feature = "security")]
8551 let beacon_ready =
8552 !(rt.config.security.is_some() && rt.security_builtin_snapshot().is_none());
8553 #[cfg(not(feature = "security"))]
8554 let beacon_ready = true;
8555 if beacon_ready {
8556 let targets = wlp_unicast_targets(core::slice::from_ref(&parsed));
8557 if !targets.is_empty() {
8558 if let Some(secured) = rt
8559 .spdp_beacon
8560 .lock()
8561 .ok()
8562 .and_then(|mut b| b.serialize().ok())
8563 .and_then(|d| secure_outbound_bytes(rt, &d).map(|c| c.to_vec()))
8564 {
8565 for loc in &targets {
8566 let _ = rt.spdp_unicast.send(loc, &secured);
8567 }
8568 }
8569 }
8570 }
8571 }
8572 // FU2: wire the security builtin stack + kick off the auth handshake.
8573 // On EVERY beacon (not only is_new): `handle_remote_endpoints` and
8574 // `begin_handshake_with` are idempotent. This also covers the case
8575 // that the peer was discovered before the auth plugin was active via
8576 // `enable_security_builtins_with_auth` — the next
8577 // beacon refresh then kicks off the handshake. No-op without a plugin,
8578 // without security bits or without an announced identity_token.
8579 if let Some(sec) = rt.security_builtin_snapshot() {
8580 let handshake_dgs = if let Ok(mut s) = sec.lock() {
8581 s.note_remote_vendor(parsed.sender_prefix, parsed.sender_vendor);
8582 s.handle_remote_endpoints(&parsed);
8583 match parsed.data.identity_token.as_ref() {
8584 Some(token) => s
8585 .begin_handshake_with(parsed.sender_prefix, parsed.data.guid.to_bytes(), token)
8586 .unwrap_or_default(),
8587 None => Vec::new(),
8588 }
8589 } else {
8590 Vec::new()
8591 };
8592 for dg in handshake_dgs {
8593 send_discovery_datagram(rt, &dg.targets, &dg.bytes);
8594 }
8595 }
8596 // Mirror the SPDP receive into the builtin DCPSParticipant reader.
8597 // We send on every beacon (also refresh) — Spec §2.2.5.1
8598 // allows it, take() returns the respective current
8599 // data to the user. A reader with KEEP_LAST(1) receives only the newest.
8600 if let Some(sinks) = rt.builtin_sinks_snapshot() {
8601 let dcps_sample =
8602 crate::builtin_topics::ParticipantBuiltinTopicData::from_wire(&parsed.data);
8603 // .7 §2.2.2.2.1.14: drop ignored participants before
8604 // they fall into the builtin reader.
8605 if let Some(filter) = rt.ignore_filter_snapshot() {
8606 let h = crate::instance_handle::InstanceHandle::from_guid(dcps_sample.key);
8607 if filter.is_participant_ignored(h) {
8608 return;
8609 }
8610 }
8611 let _ = sinks.push_participant(&dcps_sample);
8612 }
8613}
8614
8615/// Pushes SEDP events (new pubs/subs) into the 4 builtin-topic
8616/// readers. A new pub/sub produces **two** samples:
8617///
8618/// 1. a `DCPSPublication`/`DCPSSubscription` sample,
8619/// 2. a `DCPSTopic` sample (synthetic from topic name + type name).
8620///
8621/// The native SEDP-topics endpoints (RTPS 2.5 §9.3.2.12 bits 28/29)
8622/// are optional per Spec §8.5.4.4 and covered in ZeroDDS via this
8623/// synthetic derivation — see also
8624/// `endpoint_flag::ALL_STANDARD`, which deliberately omits the
8625/// topics bits. Cyclone/Fast-DDS peers that send their own topic
8626/// announces are ignored (no reader endpoint).
8627fn push_sedp_events_to_builtin_readers(
8628 rt: &Arc<DcpsRuntime>,
8629 events: &zerodds_discovery::sedp::SedpEvents,
8630) {
8631 let Some(sinks) = rt.builtin_sinks_snapshot() else {
8632 return;
8633 };
8634 let filter = rt.ignore_filter_snapshot();
8635 for w in &events.new_publications {
8636 let pub_sample = crate::builtin_topics::PublicationBuiltinTopicData::from_wire(w);
8637 let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_publication(w);
8638 // .7 §2.2.2.2.1.14/.16: consult the participant + publication +
8639 // topic ignore filters.
8640 if let Some(f) = &filter {
8641 let part_h = crate::instance_handle::InstanceHandle::from_guid(w.participant_key);
8642 let pub_h = crate::instance_handle::InstanceHandle::from_guid(w.key);
8643 let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
8644 if f.is_participant_ignored(part_h) || f.is_publication_ignored(pub_h) {
8645 continue;
8646 }
8647 let _ = sinks.push_publication(&pub_sample);
8648 if !f.is_topic_ignored(topic_h) {
8649 let _ = sinks.push_topic(&topic_sample);
8650 }
8651 } else {
8652 let _ = sinks.push_publication(&pub_sample);
8653 let _ = sinks.push_topic(&topic_sample);
8654 }
8655 }
8656 for r in &events.new_subscriptions {
8657 let sub_sample = crate::builtin_topics::SubscriptionBuiltinTopicData::from_wire(r);
8658 let topic_sample = crate::builtin_topics::TopicBuiltinTopicData::from_subscription(r);
8659 if let Some(f) = &filter {
8660 let part_h = crate::instance_handle::InstanceHandle::from_guid(r.participant_key);
8661 let sub_h = crate::instance_handle::InstanceHandle::from_guid(r.key);
8662 let topic_h = crate::instance_handle::InstanceHandle::from_guid(topic_sample.key);
8663 if f.is_participant_ignored(part_h) || f.is_subscription_ignored(sub_h) {
8664 continue;
8665 }
8666 let _ = sinks.push_subscription(&sub_sample);
8667 if !f.is_topic_ignored(topic_h) {
8668 let _ = sinks.push_topic(&topic_sample);
8669 }
8670 } else {
8671 let _ = sinks.push_subscription(&sub_sample);
8672 let _ = sinks.push_topic(&topic_sample);
8673 }
8674 }
8675}
8676
8677/// Binary-property name of the crypto key material in the CryptoToken DataHolder
8678/// (DDS-Security §9.5.2.1.1, cyclone-verified: `dds.cryp.keymat`).
8679#[cfg(feature = "security")]
8680const CRYPTO_TOKEN_PROP: &str = "dds.cryp.keymat";
8681
8682/// CryptoToken `class_id` (§9.5.2.1: `DDS:Crypto:AES_GCM_GMAC` — underscores,
8683/// **not** the plugin-class string with hyphens).
8684#[cfg(feature = "security")]
8685const CRYPTO_TOKEN_CLASS_ID: &str = "DDS:Crypto:AES_GCM_GMAC";
8686
8687/// Builds the `PARTICIPANT_CRYPTO_TOKENS` VolatileSecure message with the
8688/// Kx-encrypted token as a binary property (FU2 S1.4).
8689#[cfg(feature = "security")]
8690fn build_crypto_token_message(
8691 rt: &DcpsRuntime,
8692 remote_prefix: GuidPrefix,
8693 kx_token: Vec<u8>,
8694) -> zerodds_security::generic_message::ParticipantGenericMessage {
8695 use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
8696 use zerodds_security::token::DataHolder;
8697 ParticipantGenericMessage {
8698 message_identity: MessageIdentity {
8699 source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
8700 sequence_number: 1,
8701 },
8702 related_message_identity: MessageIdentity::default(),
8703 destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
8704 destination_endpoint_key: [0; 16],
8705 source_endpoint_key: [0; 16],
8706 message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
8707 message_data: alloc::vec![
8708 DataHolder::new(CRYPTO_TOKEN_CLASS_ID)
8709 .with_binary_property(CRYPTO_TOKEN_PROP, kx_token)
8710 ],
8711 }
8712}
8713
8714/// FU2 S1.4 (send): after handshake completion Kx-encrypt the local data token
8715/// (`gate.local_token`) and send it as
8716/// `PARTICIPANT_CRYPTO_TOKENS` over VolatileSecure.
8717/// Registers the peer's Kx key in the gate beforehand. `None` without a gate
8718/// or on error (drop instead of leak).
8719#[cfg(feature = "security")]
8720fn prepare_crypto_token(
8721 rt: &DcpsRuntime,
8722 remote_prefix: GuidPrefix,
8723 remote_identity: zerodds_security::authentication::IdentityHandle,
8724 secret: zerodds_security::authentication::SharedSecretHandle,
8725) -> Option<zerodds_security::generic_message::ParticipantGenericMessage> {
8726 let gate = rt.config.security.as_ref()?;
8727 let peer_key = remote_prefix.to_bytes();
8728 // ALWAYS register the peer's Kx key — even with rtps=NONE: the per-endpoint
8729 // tokens (discovery_/data_protection) travel Kx-protected over the volatile,
8730 // protect_volatile_datagram needs this key.
8731 gate.register_remote_by_guid_from_secret(peer_key, remote_identity, secret)
8732 .ok()?;
8733 // BUT: send the ParticipantCryptoToken (= SRTPS keymat) ONLY when
8734 // rtps_protection != NONE. With rtps=NONE there is no SRTPS; OpenDDS rejects the
8735 // token (Spdp.cpp:1966 `crypto_handle_==NIL` -> "not configured for RTPS
8736 // Protection", logs `handle_participant_crypto_tokens failed`) and OpenDDS-self
8737 // also does NOT exchange it with rtps=NONE. None here = no participant
8738 // token send; the per-endpoint tokens continue over the separate path.
8739 if gate.rtps_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
8740 return None;
8741 }
8742 // Cross-vendor: the data token travels in PLAINTEXT in the
8743 // ParticipantGenericMessage — it becomes confidential only through the
8744 // SEC_PREFIX/BODY/POSTFIX submessage protection of the whole volatile
8745 // DATA (see protect_volatile_datagram). The `register_*` line above
8746 // created the peer's Kx key in the gate that this protection uses.
8747 let token = gate.local_token().ok()?;
8748 Some(build_crypto_token_message(rt, remote_prefix, token))
8749}
8750
8751/// Per-endpoint crypto handle for a local writer/reader (get-or-register).
8752/// DDS-Security §9.5.3.3: each endpoint has its OWN key material. Registration
8753/// under the write lock (race-free). `None` without an active gate.
8754#[cfg(feature = "security")]
8755fn local_endpoint_crypto_handle(
8756 rt: &DcpsRuntime,
8757 eid: EntityId,
8758 is_writer: bool,
8759) -> Option<zerodds_security::crypto::CryptoHandle> {
8760 let gate = rt.config.security.as_ref()?;
8761 {
8762 let map = rt.endpoint_crypto.read().ok()?;
8763 if let Some(h) = map.get(&eid) {
8764 return Some(*h);
8765 }
8766 }
8767 let mut map = rt.endpoint_crypto.write().ok()?;
8768 if let Some(h) = map.get(&eid) {
8769 return Some(*h);
8770 }
8771 let h = gate.register_local_endpoint(is_writer).ok()?;
8772 map.insert(eid, h);
8773 Some(h)
8774}
8775
8776/// Cross-vendor step 6b (send): per-endpoint `datawriter_crypto_tokens` (for
8777/// every local user writer) + `datareader_crypto_tokens` (for every local
8778/// user reader) to the peer. cyclone needs these to approve the user-endpoint
8779/// match and decode ZeroDDS' user DATA. `source_endpoint_key` = the
8780/// local endpoint GUID; the keymat is the local data key (one key per
8781/// participant in the bench). Empty list without a gate / without user endpoints.
8782#[cfg(feature = "security")]
8783fn prepare_endpoint_crypto_tokens(
8784 rt: &DcpsRuntime,
8785 remote_prefix: GuidPrefix,
8786) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
8787 use zerodds_security::generic_message::{MessageIdentity, ParticipantGenericMessage, class_id};
8788 use zerodds_security::token::DataHolder;
8789 let Some(gate) = rt.config.security.as_ref() else {
8790 return Vec::new();
8791 };
8792 let mut out = Vec::new();
8793 // cyclone associates a datawriter/datareader token via the pair
8794 // (source_endpoint, destination_endpoint). Hence per local endpoint ONE
8795 // token PER matched remote endpoint of **this** peer, with the concrete
8796 // remote GUID as destination_endpoint_key (dst=0 would make cyclone discard it).
8797 //
8798 // §9.5.3.3: the token carries the **per-endpoint** key material of the
8799 // `source_eid` (not the participant key) — the same key with which
8800 // ZeroDDS encodes this endpoint's submessages (protect_user_datagram).
8801 let build = |class: &str,
8802 source_eid: EntityId,
8803 dst: [u8; 16]|
8804 -> Option<ParticipantGenericMessage> {
8805 let is_writer = class == class_id::DATAWRITER_CRYPTO_TOKENS;
8806 let handle = local_endpoint_crypto_handle(rt, source_eid, is_writer)?;
8807 let token = gate.create_endpoint_token(handle).ok()?;
8808 // Dual key (metadata != data, meta-sign-data): cyclone expects
8809 // num_key_mat=2 — submessage keymat (metadata kind) + payload keymat
8810 // (data kind) as TWO DataHolders in this order. Single key
8811 // (all other profiles): only the submessage/endpoint keymat.
8812 let mut dhs = alloc::vec![
8813 DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, token)
8814 ];
8815 if let Some(pay) = gate.endpoint_payload_token(handle) {
8816 dhs.push(
8817 DataHolder::new(CRYPTO_TOKEN_CLASS_ID).with_binary_property(CRYPTO_TOKEN_PROP, pay),
8818 );
8819 }
8820 Some(ParticipantGenericMessage {
8821 message_identity: MessageIdentity {
8822 source_guid: Guid::new(rt.guid_prefix, EntityId::PARTICIPANT).to_bytes(),
8823 sequence_number: 1,
8824 },
8825 related_message_identity: MessageIdentity::default(),
8826 destination_participant_key: Guid::new(remote_prefix, EntityId::PARTICIPANT).to_bytes(),
8827 destination_endpoint_key: dst,
8828 source_endpoint_key: Guid::new(rt.guid_prefix, source_eid).to_bytes(),
8829 message_class_id: class.into(),
8830 message_data: dhs,
8831 })
8832 };
8833 // datawriter tokens: per local writer for every matched remote reader
8834 // of this peer (dst = reader GUID).
8835 for (weid, warc) in rt.writer_slots_snapshot() {
8836 if let Ok(slot) = warc.lock() {
8837 for proxy in slot.writer.reader_proxies() {
8838 if proxy.remote_reader_guid.prefix == remote_prefix {
8839 out.extend(build(
8840 class_id::DATAWRITER_CRYPTO_TOKENS,
8841 weid,
8842 proxy.remote_reader_guid.to_bytes(),
8843 ));
8844 }
8845 }
8846 }
8847 }
8848 // datareader tokens: per local reader for every matched remote writer
8849 // of this peer (dst = writer GUID).
8850 for (reid, rarc) in rt.reader_slots_snapshot() {
8851 if let Ok(slot) = rarc.lock() {
8852 for ws in slot.reader.writer_proxies() {
8853 if ws.proxy.remote_writer_guid.prefix == remote_prefix {
8854 out.extend(build(
8855 class_id::DATAREADER_CRYPTO_TOKENS,
8856 reid,
8857 ws.proxy.remote_writer_guid.to_bytes(),
8858 ));
8859 }
8860 }
8861 }
8862 }
8863 // Protected discovery (§8.4.2.4): the secure builtin SEDP endpoints
8864 // (DCPSPublications/SubscriptionsSecure) also need crypto tokens,
8865 // so the peer associates ZeroDDS' data key with them + decodes the secure-SEDP
8866 // submessages. cyclone exchanges these builtin-endpoint tokens
8867 // the same way over the volatile (ff0003c2/c7 + ff0004c2/c7).
8868 if gate
8869 .discovery_protection()
8870 .map(|l| l != ProtectionLevel::None)
8871 .unwrap_or(false)
8872 {
8873 let builtin_pairs = [
8874 (
8875 class_id::DATAWRITER_CRYPTO_TOKENS,
8876 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
8877 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
8878 ),
8879 (
8880 class_id::DATAREADER_CRYPTO_TOKENS,
8881 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER,
8882 EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER,
8883 ),
8884 (
8885 class_id::DATAWRITER_CRYPTO_TOKENS,
8886 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
8887 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
8888 ),
8889 (
8890 class_id::DATAREADER_CRYPTO_TOKENS,
8891 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER,
8892 EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER,
8893 ),
8894 ];
8895 for (class, src_eid, dst_eid) in builtin_pairs {
8896 out.extend(build(
8897 class,
8898 src_eid,
8899 Guid::new(remote_prefix, dst_eid).to_bytes(),
8900 ));
8901 }
8902 }
8903 // FastDDS interop: the reliable secure-SPDP builtin (DCPSParticipantsSecure,
8904 // ff0101c2/c7) needs per-endpoint crypto tokens when FastDDS SEC-encrypts the secure-
8905 // SPDP DATA under discovery_protection — otherwise the peer cannot
8906 // decode our secure SPDP -> no secure participant discovery ->
8907 // no token reciprocation. Gated on enable_secure_spdp.
8908 if rt.config.enable_secure_spdp {
8909 let spdp_pairs = [
8910 (
8911 class_id::DATAWRITER_CRYPTO_TOKENS,
8912 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
8913 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
8914 ),
8915 (
8916 class_id::DATAREADER_CRYPTO_TOKENS,
8917 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_READER,
8918 EntityId::SPDP_RELIABLE_BUILTIN_PARTICIPANTS_SECURE_WRITER,
8919 ),
8920 ];
8921 for (class, src_eid, dst_eid) in spdp_pairs {
8922 out.extend(build(
8923 class,
8924 src_eid,
8925 Guid::new(remote_prefix, dst_eid).to_bytes(),
8926 ));
8927 }
8928 }
8929 // Liveliness protection (§8.4.2.4): the secure-WLP builtin endpoints
8930 // (BuiltinParticipantMessageSecure, ff0200c2/c7) also need per-
8931 // endpoint crypto tokens. cyclone gates the participant security approval
8932 // (and thus the user-endpoint connection) on it — without these tokens
8933 // "connect ... waiting for approval by security" stays hung.
8934 if gate
8935 .liveliness_protection()
8936 .map(|l| l != ProtectionLevel::None)
8937 .unwrap_or(false)
8938 {
8939 let wlp_pairs = [
8940 (
8941 class_id::DATAWRITER_CRYPTO_TOKENS,
8942 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
8943 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
8944 ),
8945 (
8946 class_id::DATAREADER_CRYPTO_TOKENS,
8947 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_READER,
8948 EntityId::BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER,
8949 ),
8950 ];
8951 for (class, src_eid, dst_eid) in wlp_pairs {
8952 out.extend(build(
8953 class,
8954 src_eid,
8955 Guid::new(remote_prefix, dst_eid).to_bytes(),
8956 ));
8957 }
8958 }
8959 out
8960}
8961
8962/// Dedup key of a per-endpoint crypto token: the pair
8963/// (source_endpoint, destination_endpoint). cyclone associates a
8964/// datawriter/datareader token via exactly this pair (§9.5.3.3), so it is
8965/// also the right granularity to remember which tokens have gone out.
8966#[cfg(feature = "security")]
8967fn endpoint_token_key(
8968 m: &zerodds_security::generic_message::ParticipantGenericMessage,
8969) -> [u8; 32] {
8970 let mut k = [0u8; 32];
8971 k[..16].copy_from_slice(&m.source_endpoint_key);
8972 k[16..].copy_from_slice(&m.destination_endpoint_key);
8973 k
8974}
8975
8976/// Filters out the per-endpoint tokens not yet sent. The previously
8977/// used **per-peer** once-guard was too coarse: it snapped shut as soon as the
8978/// participant/secure-SEDP builtin tokens were out — but user endpoints match
8979/// only later (after the secure SEDP). Their tokens then never went out,
8980/// and the peer could never decode ZeroDDS' user DATA. Per-token dedup
8981/// (peer+source+dest) sends each token exactly once — builtins early,
8982/// user endpoints as soon as they match.
8983#[cfg(feature = "security")]
8984fn pending_endpoint_tokens(
8985 msgs: Vec<zerodds_security::generic_message::ParticipantGenericMessage>,
8986 already_sent: &alloc::collections::BTreeSet<[u8; 32]>,
8987) -> Vec<zerodds_security::generic_message::ParticipantGenericMessage> {
8988 msgs.into_iter()
8989 .filter(|m| !already_sent.contains(&endpoint_token_key(m)))
8990 .collect()
8991}
8992
8993/// FU2 S1.4 (recv): Kx-decrypt an incoming `PARTICIPANT_CRYPTO_TOKENS` message
8994/// and install the peer's data key in the gate.
8995/// Afterwards secured user DATA round-trips with this peer.
8996#[cfg(feature = "security")]
8997fn install_crypto_token(
8998 rt: &DcpsRuntime,
8999 remote_prefix: GuidPrefix,
9000 msg: &zerodds_security::generic_message::ParticipantGenericMessage,
9001) {
9002 use zerodds_security::generic_message::class_id;
9003 // Cross-vendor: cyclone sends the data key both as
9004 // participant_crypto_tokens and per-endpoint as datawriter/
9005 // datareader_crypto_tokens. We install the keymat from all three
9006 // under the sender's participant slot (one user endpoint per participant
9007 // in the bench) — so decode_data_datawriter_from decodes the user DATA.
9008 if msg.message_class_id != class_id::PARTICIPANT_CRYPTO_TOKENS
9009 && msg.message_class_id != class_id::DATAWRITER_CRYPTO_TOKENS
9010 && msg.message_class_id != class_id::DATAREADER_CRYPTO_TOKENS
9011 {
9012 return;
9013 }
9014 let Some(gate) = rt.config.security.as_ref() else {
9015 return;
9016 };
9017 let peer_key = remote_prefix.to_bytes();
9018 // `message_data` is a sequence<DataHolder> (DDS-Security §7.4.4.3
9019 // ParticipantGenericMessage): cyclone packs MULTIPLE CryptoTokens (its own
9020 // key material per endpoint, different transformation_key_id) into ONE
9021 // message. Install ALL — taking only `.first()` lost the
9022 // endpoint keys (key_id 2..N) and the secure SEDP stayed undecodable.
9023 // Plaintext token (confidentiality was provided by the submessage protection of
9024 // the transporting volatile DATA, see unprotect_volatile_datagram).
9025 // DDS-Security §9.5.2 vs §9.5.3: the PARTICIPANT crypto token carries the
9026 // message-level key (SRTPS, decode_secured_rtps_message -> slots[peer]); the
9027 // datawriter/datareader tokens carry per-endpoint data keys that belong ONLY in
9028 // the key_id path (remote_by_key_id, decode_data_by_key_id). Putting both
9029 // into slots[peer] let the last-installed (datareader) overwrite the
9030 // participant key -> message-level SRTPS tag mismatch.
9031 let is_participant = msg.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS;
9032 for dh in &msg.message_data {
9033 if let Some(token) = dh.binary_property(CRYPTO_TOKEN_PROP) {
9034 let _ = if is_participant {
9035 gate.set_remote_data_token_by_guid(&peer_key, token)
9036 } else {
9037 gate.install_remote_endpoint_token(token)
9038 };
9039 }
9040 }
9041}
9042
9043// RTPS submessage IDs for the VolatileSecure submessage-protection surgery.
9044#[cfg(feature = "security")]
9045const SMID_DATA: u8 = 0x15;
9046#[cfg(feature = "security")]
9047const SMID_SEC_PREFIX: u8 = 0x31;
9048#[cfg(feature = "security")]
9049const SMID_SEC_POSTFIX: u8 = 0x32;
9050// Further writer submessage IDs (DDSI-RTPS 2.5 §8.3.7). Per DDS-Security
9051// §8.4.2.4 (is_submessage_protected=TRUE, DataWriter) ALL submessages sent by the
9052// writer — not only DATA — MUST be protected via encode_datawriter_submessage.
9053// HEARTBEAT is the critical one: without it the remote
9054// reader cannot NACK a missing sequence number (= no reliable recovery).
9055#[cfg(feature = "security")]
9056const SMID_HEARTBEAT: u8 = 0x07;
9057#[cfg(feature = "security")]
9058const SMID_GAP: u8 = 0x08;
9059#[cfg(feature = "security")]
9060const SMID_DATA_FRAG: u8 = 0x16;
9061#[cfg(feature = "security")]
9062const SMID_HEARTBEAT_FRAG: u8 = 0x13;
9063// Reader submessages (DDSI-RTPS 2.5 §8.3.7): under `metadata_protection_kind
9064// != NONE` to be protected via `encode_datareader_submessage` (§8.4.2.4) with the per-endpoint
9065// reader key — otherwise a spec-conformant remote writer
9066// (cyclone under discovery=ENCRYPT) discards the clear ACKNACK and never re-sends.
9067#[cfg(feature = "security")]
9068const SMID_ACKNACK: u8 = 0x06;
9069#[cfg(feature = "security")]
9070const SMID_NACK_FRAG: u8 = 0x12;
9071
9072/// `true` if the submessage ID is a submessage sent by the DataReader
9073/// (ACKNACK/NACK_FRAG) — datareader protection path.
9074#[cfg(feature = "security")]
9075fn is_protected_reader_submessage(id: u8) -> bool {
9076 matches!(id, SMID_ACKNACK | SMID_NACK_FRAG)
9077}
9078
9079/// Extracts the `reader_id` (sender) from an ACKNACK/NACK_FRAG submessage:
9080/// offset 4 (after header(4)), directly before the writer_id (offset 8).
9081#[cfg(feature = "security")]
9082fn reader_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
9083 if !is_protected_reader_submessage(id) {
9084 return None;
9085 }
9086 let raw: [u8; 4] = submsg.get(4..8)?.try_into().ok()?;
9087 Some(EntityId::from_bytes(raw))
9088}
9089
9090/// `true` if the submessage ID is a submessage sent by the DataWriter that,
9091/// under `metadata_protection_kind != NONE`, must be protected via `encode_datawriter_submessage`
9092/// (DDS-Security §8.4.2.4). ACKNACK/NACK_FRAG are
9093/// reader submessages (datareader path) and are excluded here.
9094#[cfg(feature = "security")]
9095fn is_protected_writer_submessage(id: u8) -> bool {
9096 matches!(
9097 id,
9098 SMID_DATA | SMID_DATA_FRAG | SMID_HEARTBEAT | SMID_HEARTBEAT_FRAG | SMID_GAP
9099 )
9100}
9101
9102/// Walks the submessages of an RTPS datagram from `offset` and returns
9103/// `(submessage_id, start, total_len)`. `octetsToNextHeader == 0` means
9104/// "to the end of the datagram" (RTPS §8.3.3.2.3).
9105#[cfg(feature = "security")]
9106fn walk_submessages(bytes: &[u8]) -> Vec<(u8, usize, usize)> {
9107 let mut out = Vec::new();
9108 let mut o = 20; // RTPS header
9109 while o + 4 <= bytes.len() {
9110 let id = bytes[o];
9111 let le = bytes[o + 1] & 0x01 != 0;
9112 let raw = if le {
9113 u16::from_le_bytes([bytes[o + 2], bytes[o + 3]])
9114 } else {
9115 u16::from_be_bytes([bytes[o + 2], bytes[o + 3]])
9116 } as usize;
9117 let body = if raw == 0 { bytes.len() - (o + 4) } else { raw };
9118 let total = 4 + body;
9119 if o + total > bytes.len() {
9120 break;
9121 }
9122 out.push((id, o, total));
9123 o += total;
9124 }
9125 out
9126}
9127
9128/// Cross-vendor VolatileSecure (send): replaces every DATA submessage in the
9129/// datagram with the cyclone-conformant `SEC_PREFIX`/`SEC_BODY`/`SEC_POSTFIX`
9130/// sequence (encrypted with the peer's Kx key). Other submessages
9131/// (INFO_DST/INFO_TS/HEARTBEAT) stay unchanged. Returns the datagram
9132/// unchanged if no DATA submessage is present (e.g. a pure
9133/// HEARTBEAT tick). `None` only on a crypto error (drop instead of leak).
9134#[cfg(feature = "security")]
9135fn protect_volatile_datagram(
9136 rt: &DcpsRuntime,
9137 bytes: &[u8],
9138 peer_key: &[u8; 12],
9139) -> Option<Vec<u8>> {
9140 let gate = rt.config.security.as_ref()?;
9141 if bytes.len() < 20 {
9142 return Some(bytes.to_vec());
9143 }
9144 let subs = walk_submessages(bytes);
9145 // DDS-Security §8.4.2.4: ParticipantVolatileMessageSecure is submessage-
9146 // protected — ALL submessages sent by the endpoint MUST be protected with the Kx key,
9147 // not only DATA. This holds for BOTH directions:
9148 // * writer submessages (DATA, DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP)
9149 // * reader submessages (ACKNACK, NACK_FRAG)
9150 // cyclone/FastDDS otherwise discard the WHOLE volatile sample with "clear
9151 // submsg from protected src" → the crypto-token exchange over the volatile
9152 // stalls. write_with_heartbeat bundles DATA+HEARTBEAT into ONE datagram; if
9153 // the HEARTBEAT stayed clear, the whole token sample was lost (cross-vendor
9154 // cyclone→ZeroDDS responder).
9155 // The reader ACKNACK: OpenDDS' RtpsUdpReceiveStrategy::check_encoded requires
9156 // protection for the volatile reader (ff0202c4, is_submessage_protected=TRUE) and
9157 // otherwise drops the clear ACKNACK ("Submessage requires protection") → its
9158 // volatile WRITER never gets an ACK → considers the token delivery
9159 // unacknowledged → zerodds NEVER sends the SRTPS-protected secure SEDP → no
9160 // user-endpoint match. The volatile channel uses ONE shared Kx session key
9161 // (KDF from the shared secret, §9.5.3.3.4.4), symmetric for both directions
9162 // → protect the ACKNACK with the same Kx key as the DATA.
9163 if !subs.iter().any(|(id, _, _)| {
9164 is_protected_writer_submessage(*id) || is_protected_reader_submessage(*id)
9165 }) {
9166 return Some(bytes.to_vec()); // no protection-worthy submessage -> unchanged
9167 }
9168 let mut out = Vec::with_capacity(bytes.len() + 64);
9169 out.extend_from_slice(&bytes[..20]);
9170 for (id, start, total) in subs {
9171 let submsg = &bytes[start..start + total];
9172 if is_protected_writer_submessage(id) || is_protected_reader_submessage(id) {
9173 match gate.encode_kx_datawriter_for(peer_key, submsg) {
9174 Ok(sec) => out.extend_from_slice(&sec),
9175 Err(_) => return None, // drop instead of plaintext leak
9176 }
9177 } else {
9178 out.extend_from_slice(submsg);
9179 }
9180 }
9181 Some(out)
9182}
9183
9184/// Cross-vendor VolatileSecure (recv): recognizes a `SEC_PREFIX`/`SEC_BODY`/
9185/// `SEC_POSTFIX` sequence, decodes it with the peer's Kx key to the
9186/// original DATA submessage and builds a plain RTPS datagram for the
9187/// `volatile_reader`. `None` if no SEC_* sequence is present (then the normal
9188/// path) or on a crypto error.
9189#[cfg(feature = "security")]
9190fn unprotect_volatile_datagram(
9191 rt: &DcpsRuntime,
9192 bytes: &[u8],
9193 peer_key: &[u8; 12],
9194) -> Option<Vec<u8>> {
9195 let gate = rt.config.security.as_ref()?;
9196 if bytes.len() < 20 {
9197 return None;
9198 }
9199 let subs = walk_submessages(bytes);
9200 // Cyclone/FastDDS bundle, via xpack, MULTIPLE SEC_*-protected volatile
9201 // submessages (all with the Kx key) into ONE datagram. So there can be
9202 // multiple SEC_PREFIX/BODY/POSTFIX triples — transform ALL back
9203 // (like unprotect_user_datagram). Decoding only the first block (an earlier
9204 // bug) left every bundled token sample after the first encrypted;
9205 // the VOLATILE writer does not retransmit them → deterministic
9206 // token loss (no "flaky" transport, all same-host). `None` if there is no
9207 // SEC_PREFIX at all (plaintext) or the Kx decode fails (= not a volatile datagram,
9208 // e.g. secure SEDP with a per-endpoint key).
9209 if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
9210 return None;
9211 }
9212 let mut out = Vec::with_capacity(bytes.len());
9213 out.extend_from_slice(&bytes[..20]);
9214 let mut i = 0;
9215 while i < subs.len() {
9216 let (id, start, total) = subs[i];
9217 if id == SMID_SEC_PREFIX {
9218 let postfix_idx = subs[i..]
9219 .iter()
9220 .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
9221 .map(|off| i + off)?;
9222 let (_, q_start, q_total) = subs[postfix_idx];
9223 let sec_wire = &bytes[start..q_start + q_total];
9224 let submsg = gate.decode_kx_datawriter_from(peer_key, sec_wire).ok()?;
9225 out.extend_from_slice(&submsg);
9226 i = postfix_idx + 1;
9227 } else {
9228 out.extend_from_slice(&bytes[start..start + total]);
9229 i += 1;
9230 }
9231 }
9232 Some(out)
9233}
9234
9235/// Protects a peer's volatile outbound datagrams (DATA -> SEC_*).
9236/// HEARTBEAT/ACKNACK datagrams (without DATA) stay unchanged; datagrams
9237/// with a crypto error are dropped.
9238#[cfg(feature = "security")]
9239fn protect_volatile_outbound(
9240 rt: &DcpsRuntime,
9241 remote_prefix: GuidPrefix,
9242 dgs: Vec<zerodds_rtps::message_builder::OutboundDatagram>,
9243) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
9244 let peer_key = remote_prefix.to_bytes();
9245 dgs.into_iter()
9246 .filter_map(|dg| {
9247 protect_volatile_datagram(rt, &dg.bytes, &peer_key).map(|bytes| {
9248 zerodds_rtps::message_builder::OutboundDatagram {
9249 bytes,
9250 targets: dg.targets,
9251 }
9252 })
9253 })
9254 .collect()
9255}
9256
9257/// Cross-vendor (send): replaces EVERY submessage sent by the DataWriter (DATA,
9258/// DATA_FRAG, HEARTBEAT, HEARTBEAT_FRAG, GAP) with the cyclone-conformant
9259/// SEC_PREFIX/BODY/POSTFIX sequence, encrypted with the **local data key**.
9260/// DDS-Security §8.4.2.4 (`is_submessage_protected=TRUE`, DataWriter): ALL
9261/// writer submessages MUST be protected via `encode_datawriter_submessage`
9262/// — in particular the HEARTBEAT, otherwise the remote reader cannot NACK missing
9263/// sequence numbers (no reliable recovery). Framing submessages
9264/// (INFO_TS/INFO_DST/...) stay unchanged; `None` on a crypto error.
9265#[cfg(feature = "security")]
9266fn protect_user_datagram(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
9267 let gate = rt.config.security.as_ref()?;
9268 if bytes.len() < 20 {
9269 return Some(bytes.to_vec());
9270 }
9271 let subs = walk_submessages(bytes);
9272 if !subs
9273 .iter()
9274 .any(|(id, _, _)| is_protected_writer_submessage(*id))
9275 {
9276 return Some(bytes.to_vec());
9277 }
9278 // §9.5.3.3 per-endpoint key: all writer submessages of a datagram
9279 // come from the same writer. Take the writer_id from the first protected
9280 // submessage + look up the per-endpoint handle. No handle
9281 // (unregistered endpoint) → participant-key fallback.
9282 let endpoint_handle = subs
9283 .iter()
9284 .find(|(id, _, _)| is_protected_writer_submessage(*id))
9285 .and_then(|&(id, start, total)| writer_eid_in_submessage(&bytes[start..start + total], id))
9286 .and_then(|weid| local_endpoint_crypto_handle(rt, weid, true));
9287 let mut out = Vec::with_capacity(bytes.len() + 64);
9288 out.extend_from_slice(&bytes[..20]);
9289 for (id, start, total) in subs {
9290 let submsg = &bytes[start..start + total];
9291 if is_protected_writer_submessage(id) {
9292 let sec = match endpoint_handle {
9293 Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
9294 None => gate.encode_data_datawriter_local(submsg),
9295 };
9296 match sec {
9297 Ok(s) => out.extend_from_slice(&s),
9298 Err(_) => return None,
9299 }
9300 } else {
9301 out.extend_from_slice(submsg);
9302 }
9303 }
9304 Some(out)
9305}
9306
9307/// Extracts the `writer_id` from an RTPS writer submessage. DATA/DATA_FRAG:
9308/// offset 12 (header(4)+extraFlags(2)+octetsToInlineQos(2)+readerId(4));
9309/// HEARTBEAT/GAP/HEARTBEAT_FRAG: offset 8 (header(4)+readerId(4)).
9310#[cfg(feature = "security")]
9311fn writer_eid_in_submessage(submsg: &[u8], id: u8) -> Option<EntityId> {
9312 let off = match id {
9313 SMID_DATA | SMID_DATA_FRAG => 12,
9314 SMID_HEARTBEAT | SMID_GAP | SMID_HEARTBEAT_FRAG => 8,
9315 _ => return None,
9316 };
9317 let raw: [u8; 4] = submsg.get(off..off + 4)?.try_into().ok()?;
9318 Some(EntityId::from_bytes(raw))
9319}
9320
9321/// Cross-vendor user DATA (recv): decodes the SEC_* sequence with the sender's
9322/// data key (`peer_key` = sender GuidPrefix) back to the DATA submessage.
9323/// `None` if no SEC_* sequence is present (normal path) or on a crypto error.
9324#[cfg(feature = "security")]
9325fn unprotect_user_datagram(rt: &DcpsRuntime, bytes: &[u8], peer_key: &[u8; 12]) -> Option<Vec<u8>> {
9326 let gate = rt.config.security.as_ref()?;
9327 if bytes.len() < 20 {
9328 return None;
9329 }
9330 let subs = walk_submessages(bytes);
9331 // §8.4.2.4: the peer SEC_*-wrapped EVERY writer submessage individually
9332 // (DATA, HEARTBEAT, GAP, ...). So there can be MULTIPLE SEC_PREFIX/BODY/
9333 // POSTFIX triples in the same datagram — transform them all back. `None`
9334 // only if there is no SEC_* sequence at all (normal/plaintext path).
9335 if !subs.iter().any(|(id, _, _)| *id == SMID_SEC_PREFIX) {
9336 return None;
9337 }
9338 let mut out = Vec::with_capacity(bytes.len());
9339 out.extend_from_slice(&bytes[..20]);
9340 let mut i = 0;
9341 while i < subs.len() {
9342 let (id, start, total) = subs[i];
9343 if id == SMID_SEC_PREFIX {
9344 // Find the matching SEC_POSTFIX from i; the block is [prefix..postfix].
9345 let postfix_idx = subs[i..]
9346 .iter()
9347 .position(|(sid, _, _)| *sid == SMID_SEC_POSTFIX)
9348 .map(|off| i + off)?;
9349 let (_, q_start, q_total) = subs[postfix_idx];
9350 let sec_wire = &bytes[start..q_start + q_total];
9351 // key_id-based decode: the peer has, per endpoint (user +
9352 // secure-builtin discovery), its own key material; the correct
9353 // key is found via the transformation_key_id in the CryptoHeader.
9354 // Fallback for transformation_key_id=0: this is NOT a per-
9355 // endpoint token key, but the participant-level key derived from the
9356 // SharedSecret (DDS-Security Tab.73, AES256-GCM, sender_key_id
9357 // =0) — cyclone protects with it under rtps_protection. That one is decoded by the
9358 // Kx path (peer-prefix-indexed SharedSecret key).
9359 let mut submsg = gate
9360 .decode_data_by_key_id(sec_wire)
9361 .or_else(|_| gate.decode_data_datawriter_from(peer_key, sec_wire))
9362 .or_else(|_| gate.decode_kx_datawriter_from(peer_key, sec_wire))
9363 .ok()?;
9364 // Correct octetsToNextHeader to the real body length: cyclone
9365 // wraps every writer submessage INDIVIDUALLY; within its SEC_BODY
9366 // it is the last one -> octetsToNextHeader=0 ("to the end of the message").
9367 // When concatenating multiple decoded blocks (e.g. DATA + piggybacked
9368 // HEARTBEAT), otn=0 would make the strict decode_datagram swallow the following
9369 // submessage as payload -> the reader would never see the
9370 // HEARTBEAT and would block as a late joiner on the SN gap.
9371 if submsg.len() >= 4 {
9372 let le = submsg[1] & zerodds_rtps::FLAG_E_LITTLE_ENDIAN != 0;
9373 let otn = u16::try_from(submsg.len() - 4).unwrap_or(0);
9374 let b = if le {
9375 otn.to_le_bytes()
9376 } else {
9377 otn.to_be_bytes()
9378 };
9379 submsg[2] = b[0];
9380 submsg[3] = b[1];
9381 }
9382 out.extend_from_slice(&submsg);
9383 i = postfix_idx + 1;
9384 } else {
9385 out.extend_from_slice(&bytes[start..start + total]);
9386 i += 1;
9387 }
9388 }
9389 Some(out)
9390}
9391
9392/// §8.5.1.9.1 / §9.5.3.3.1 data_protection (send): encrypts ONLY the
9393/// SerializedPayload INSIDE each DATA submessage (payload layer). The
9394/// submessage header, octetsToInlineQos, InlineQoS and the flags (E/Q/D/K)
9395/// stay byte-identical; only the N-flag (NonStandardPayload, §8.3.8.2) is
9396/// set and octetsToNextHeader adjusted to the new payload length. This is
9397/// the spec-conformant + cyclone-interop form of data_protection (counterpart:
9398/// metadata_protection = whole submessage SEC_*-wrapped). Applied as the INNER
9399/// layer BEFORE the submessage/message protection. `None` on a
9400/// crypto error (drop instead of leak); a datagram without DATA stays unchanged.
9401#[cfg(feature = "security")]
9402fn protect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
9403 use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
9404 use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
9405 let gate = rt.config.security.as_ref()?;
9406 if bytes.len() < 20 {
9407 return Some(bytes.to_vec());
9408 }
9409 let subs = walk_submessages(bytes);
9410 if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
9411 return Some(bytes.to_vec());
9412 }
9413 let mut out = Vec::with_capacity(bytes.len() + 64);
9414 out.extend_from_slice(&bytes[..20]);
9415 for (id, start, total) in subs {
9416 let submsg = &bytes[start..start + total];
9417 if id != SMID_DATA {
9418 out.extend_from_slice(submsg);
9419 continue;
9420 }
9421 let flags = submsg[1];
9422 let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
9423 // data_protection payload key: the **per-endpoint DataWriter key**
9424 // (§9.5.3.3.1). cyclone associates the DataWriter strictly with its
9425 // datawriter_crypto_handle and decodes the SerializedPayload ONLY with
9426 // this key — the participant key yields "Invalid Crypto
9427 // Handle" in cyclone. The key is sent to the peer as a datawriter_crypto_token;
9428 // the reader finds it via the transformation_key_id in the CryptoHeader.
9429 let handle = writer_eid_in_submessage(submsg, id)
9430 .and_then(|w| local_endpoint_crypto_handle(rt, w, true))?;
9431 // Payload boundary: read_body_with_flags returns serialized_payload as
9432 // an Arc of body[pos..] -> payload = the last plen bytes of the submessage.
9433 let body = &submsg[4..];
9434 let ds = DataSubmessage::read_body_with_flags(body, le, flags).ok()?;
9435 let plen = ds.serialized_payload.len();
9436 let payload_off = submsg.len() - plen;
9437 let enc = gate
9438 .encode_serialized_payload(handle, &ds.serialized_payload)
9439 .ok()?;
9440 let new_body_len = (payload_off - 4) + enc.len();
9441 if new_body_len > u16::MAX as usize {
9442 return None;
9443 }
9444 out.push(submsg[0]);
9445 out.push(flags | DATA_FLAG_NON_STANDARD);
9446 let otn = new_body_len as u16;
9447 if le {
9448 out.extend_from_slice(&otn.to_le_bytes());
9449 } else {
9450 out.extend_from_slice(&otn.to_be_bytes());
9451 }
9452 // Body prefix (extraFlags..InlineQoS) verbatim, then encrypted payload.
9453 out.extend_from_slice(&submsg[4..payload_off]);
9454 out.extend_from_slice(&enc);
9455 }
9456 Some(out)
9457}
9458
9459/// Result of the inner payload layer on receipt (§8.5.1.9.4).
9460#[cfg(feature = "security")]
9461enum PayloadDecode {
9462 /// No DATA submessage carries the N-flag — plaintext path, pass the datagram
9463 /// on unchanged.
9464 NotEncrypted,
9465 /// Successfully decrypted — use the plaintext datagram.
9466 Decoded(Vec<u8>),
9467 /// N-flag set, but decryption failed. The datagram MUST
9468 /// be discarded — passing an undecodable encrypted payload as
9469 /// ciphertext gives the reader garbage (§8.5: reject). The
9470 /// reliable re-send catches up on the sample once the key is installed
9471 /// resp. another (e.g. inproc/message-level) copy delivers it.
9472 Failed,
9473}
9474
9475/// `true` if the SerializedPayload begins with a CryptoHeader (§9.5.3.3.1):
9476/// the first 4 bytes are a CryptoTransformKind != NONE
9477/// (AES128_GMAC/GCM, AES256_GMAC/GCM = `[0,0,0,1..=4]`). A plaintext CDR
9478/// encapsulation carries either a different first byte pair (CDR_LE `[0,1]`,
9479/// XCDR2 `[0,6/7]`, PL_CDR `[0,2/3]`) or — for CDR_BE `[0,0]` — options
9480/// `[0,0]`, so it does not collide with the transform kinds 1..=4. Serves as
9481/// detection for vendors (cyclone) that encrypt the data_protection payload
9482/// without setting the N-flag of the DATA submessage.
9483#[cfg(feature = "security")]
9484fn payload_has_crypto_header(payload: &[u8]) -> bool {
9485 matches!(payload, [0, 0, 0, 1..=4, ..])
9486}
9487
9488/// §8.5.1.9.4 / §9.5.3.3.1 data_protection (recv): decrypts the
9489/// SerializedPayload of each DATA submessage whose payload begins with a CryptoHeader
9490/// — recognized by the set N-flag (zero↔zero, [`protect_user_payload`])
9491/// OR by the CryptoTransformKind signature (cyclone does not set the N-flag).
9492/// The tag verification of the GCM open IS the detection: if the decode fails
9493/// and the N-flag was not set, the submessage is passed through as plaintext
9494/// (false positive of the signature heuristic). The key is found via the
9495/// `transformation_key_id` (key_id), the sender prefix (peer slot) or — for
9496/// key_id=0 (participant/Kx key, cyclone) — the Kx key material.
9497/// `NotEncrypted` if no DATA submessage was decrypted; `Failed` only
9498/// on an N-flag decode error (§8.5: reject undecryptable).
9499#[cfg(feature = "security")]
9500fn unprotect_user_payload(rt: &DcpsRuntime, bytes: &[u8]) -> PayloadDecode {
9501 use zerodds_rtps::FLAG_E_LITTLE_ENDIAN;
9502 use zerodds_rtps::submessages::{DATA_FLAG_NON_STANDARD, DataSubmessage};
9503 let Some(gate) = rt.config.security.as_ref() else {
9504 return PayloadDecode::NotEncrypted;
9505 };
9506 if bytes.len() < 20 {
9507 return PayloadDecode::NotEncrypted;
9508 }
9509 // Sender prefix (RTPS header bytes[8..20]) as a fallback key index, if the
9510 // transformation_key_id in the CryptoHeader is not uniquely in the remote index
9511 // (zero↔zero indexed via the peer slot, cyclone strictly via key_id).
9512 let mut peer_key = [0u8; 12];
9513 peer_key.copy_from_slice(&bytes[8..20]);
9514 let subs = walk_submessages(bytes);
9515 let mut out = Vec::with_capacity(bytes.len());
9516 out.extend_from_slice(&bytes[..20]);
9517 let mut did_decode = false;
9518 for (id, start, total) in subs {
9519 let submsg = &bytes[start..start + total];
9520 if id != SMID_DATA {
9521 out.extend_from_slice(submsg);
9522 continue;
9523 }
9524 let flags = submsg[1];
9525 let le = flags & FLAG_E_LITTLE_ENDIAN != 0;
9526 let nflag = flags & DATA_FLAG_NON_STANDARD != 0;
9527 let body = &submsg[4..];
9528 let Ok(ds) = DataSubmessage::read_body_with_flags(body, le, flags) else {
9529 // Parse error of a DATA marked as encrypted -> drop;
9530 // a pure plaintext DATA never made read_body_with_flags fail,
9531 // so a set N-flag is the only reason here.
9532 if nflag {
9533 return PayloadDecode::Failed;
9534 }
9535 out.extend_from_slice(submsg);
9536 continue;
9537 };
9538 // Only attempt when the payload is recognizable as encrypted:
9539 // N-flag (zero↔zero) or CryptoHeader signature (cyclone without an N-flag).
9540 if !nflag && !payload_has_crypto_header(&ds.serialized_payload) {
9541 out.extend_from_slice(submsg);
9542 continue;
9543 }
9544 let plen = ds.serialized_payload.len();
9545 let payload_off = submsg.len() - plen;
9546 let pdec = gate
9547 .decode_serialized_payload(&ds.serialized_payload)
9548 .or_else(|_| gate.decode_serialized_payload_from(&peer_key, &ds.serialized_payload))
9549 .or_else(|_| gate.decode_serialized_payload_kx(&peer_key, &ds.serialized_payload));
9550 let Ok(dec) = pdec else {
9551 // §8.5: if the N-flag was set, the payload is surely encrypted
9552 // and the reader would get garbage -> drop (reliable re-send catches it
9553 // up after key install). If only the signature heuristic was the trigger
9554 // (no N-flag), it is a plaintext CDR_BE payload whose options
9555 // happen to look like a TransformKind -> pass through unchanged.
9556 if nflag {
9557 return PayloadDecode::Failed;
9558 }
9559 out.extend_from_slice(submsg);
9560 continue;
9561 };
9562 let new_body_len = (payload_off - 4) + dec.len();
9563 if new_body_len > u16::MAX as usize {
9564 return PayloadDecode::Failed;
9565 }
9566 out.push(submsg[0]);
9567 out.push(flags & !DATA_FLAG_NON_STANDARD);
9568 let otn = new_body_len as u16;
9569 if le {
9570 out.extend_from_slice(&otn.to_le_bytes());
9571 } else {
9572 out.extend_from_slice(&otn.to_be_bytes());
9573 }
9574 out.extend_from_slice(&submsg[4..payload_off]);
9575 out.extend_from_slice(&dec);
9576 did_decode = true;
9577 }
9578 if did_decode {
9579 PayloadDecode::Decoded(out)
9580 } else {
9581 PayloadDecode::NotEncrypted
9582 }
9583}
9584
9585/// `true` if the EntityId is one of the four secure-SEDP discovery endpoints
9586/// (DCPSPublicationsSecure/DCPSSubscriptionsSecure, EntityIds ff0003c2/c7 +
9587/// ff0004c2/c7). Controls whether a SEDP datagram is protected-discovery traffic
9588/// and must be SEC_*-protected (DDS-Security §8.4.2.4).
9589#[cfg(feature = "security")]
9590fn is_secure_sedp_entity(e: EntityId) -> bool {
9591 e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_WRITER
9592 || e == EntityId::SEDP_BUILTIN_PUBLICATIONS_SECURE_READER
9593 || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_WRITER
9594 || e == EntityId::SEDP_BUILTIN_SUBSCRIPTIONS_SECURE_READER
9595}
9596
9597/// `true` if the datagram carries a submessage to/from a secure-SEDP endpoint
9598/// — then it is protected-discovery traffic.
9599#[cfg(feature = "security")]
9600fn is_secure_sedp_datagram(bytes: &[u8]) -> bool {
9601 let Ok(parsed) = decode_datagram(bytes) else {
9602 return false;
9603 };
9604 parsed.submessages.iter().any(|s| {
9605 let ids = match s {
9606 ParsedSubmessage::Data(d) => [Some(d.writer_id), Some(d.reader_id)],
9607 ParsedSubmessage::DataFrag(d) => [Some(d.writer_id), Some(d.reader_id)],
9608 ParsedSubmessage::Heartbeat(h) => [Some(h.writer_id), Some(h.reader_id)],
9609 ParsedSubmessage::Gap(g) => [Some(g.writer_id), Some(g.reader_id)],
9610 ParsedSubmessage::AckNack(a) => [Some(a.writer_id), Some(a.reader_id)],
9611 ParsedSubmessage::NackFrag(n) => [Some(n.writer_id), Some(n.reader_id)],
9612 _ => [None, None],
9613 };
9614 ids.into_iter().flatten().any(is_secure_sedp_entity)
9615 })
9616}
9617
9618/// Protected discovery (DDS-Security §8.4.2.4) send: secure-SEDP datagrams
9619/// (DATA/HEARTBEAT/GAP of the secure writers) are
9620/// `encode_datawriter_submessage`-protected with the participant data key — the same key the peer installs via
9621/// `participant_crypto_tokens`. Non-secure SEDP goes through unchanged.
9622/// `None` ⟹ crypto error on secure SEDP → drop the datagram instead of a
9623/// plaintext leak.
9624#[cfg(feature = "security")]
9625fn protect_sedp_outbound(rt: &DcpsRuntime, bytes: &[u8]) -> Option<Vec<u8>> {
9626 let Some(gate) = rt.config.security.as_ref() else {
9627 return Some(bytes.to_vec());
9628 };
9629 if !is_secure_sedp_datagram(bytes) || bytes.len() < 20 {
9630 return Some(bytes.to_vec());
9631 }
9632 // Governance §8.4.2.4: discovery_protection_kind=NONE -> NO discovery
9633 // protection. Secure-SEDP entities (ff0003c7/ff0004c7) must then NOT
9634 // be per-endpoint-protected; otherwise their ACKNACKs leak as message-
9635 // level SEC_PREFIX with a never-exchanged per-endpoint key that a
9636 // peer (cyclone uses plain SEDP under discovery=NONE) discards as "Invalid Crypto
9637 // Handle". Pass through plain -> the outer rtps_protection
9638 // layer (SRTPS via secure_outbound_bytes) wraps the whole message correctly.
9639 if gate.discovery_protection().unwrap_or(ProtectionLevel::None) == ProtectionLevel::None {
9640 return Some(bytes.to_vec());
9641 }
9642 // §8.4.2.4: protect BOTH directions — writer submessages (DATA/HEARTBEAT/
9643 // GAP) with the per-endpoint writer key (encode_datawriter_submessage), reader
9644 // submessages (ACKNACK/NACK_FRAG) with the per-endpoint reader key
9645 // (encode_datareader_submessage). A spec-conformant cyclone under
9646 // discovery=ENCRYPT discards a CLEAR ACKNACK of the secure-SEDP reader →
9647 // never re-sends the SubscriptionData → ZeroDDS never discovers the reader. The
9648 // per-endpoint key (same as in the sent datareader_crypto_token)
9649 // makes the ACKNACK decodable for cyclone.
9650 let subs = walk_submessages(bytes);
9651 let mut out = Vec::with_capacity(bytes.len() + 64);
9652 out.extend_from_slice(&bytes[..20]);
9653 for (id, start, total) in subs {
9654 let submsg = &bytes[start..start + total];
9655 let handle = if is_protected_writer_submessage(id) {
9656 writer_eid_in_submessage(submsg, id)
9657 .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
9658 } else if is_protected_reader_submessage(id) {
9659 reader_eid_in_submessage(submsg, id)
9660 .and_then(|r| local_endpoint_crypto_handle(rt, r, false))
9661 } else {
9662 // Framing submessage (INFO_TS/INFO_DST/...) — unchanged.
9663 out.extend_from_slice(submsg);
9664 continue;
9665 };
9666 let sec = match handle {
9667 Some(h) => gate.encode_data_datawriter_by_handle(h, submsg),
9668 // No per-endpoint handle (should not occur for secure SEDP)
9669 // → participant-key fallback, so no plaintext leak arises.
9670 None => gate.encode_data_datawriter_local(submsg),
9671 };
9672 match sec {
9673 Ok(s) => out.extend_from_slice(&s),
9674 Err(_) => return None,
9675 }
9676 }
9677 Some(out)
9678}
9679
9680/// Protects a user-reader outbound datagram (ACKNACK/NACK_FRAG) on the
9681/// send direction (DDS-Security §8.4.2.4). Counterpart to the writer-DATA layer:
9682/// under `metadata_protection != NONE` the reader submessage too MUST be protected with the
9683/// per-endpoint reader key, otherwise a spec-strict
9684/// peer writer (cyclone/FastDDS) discards the CLEAR ACKNACK → the SN gap is never
9685/// re-sent → permanent reliable stall. Only needed when
9686/// **rtps_protection** does NOT already wrap the message as an SRTPS whole; otherwise
9687/// (and with metadata=NONE) the function delegates to `secure_outbound_bytes`.
9688#[cfg(feature = "security")]
9689fn protect_user_reader_datagram<'a>(
9690 rt: &DcpsRuntime,
9691 bytes: &'a [u8],
9692) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9693 let Some(gate) = rt.config.security.as_ref() else {
9694 return Some(alloc::borrow::Cow::Borrowed(bytes));
9695 };
9696 let metadata = gate.metadata_protection().unwrap_or(ProtectionLevel::None);
9697 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
9698 // rtps != None → SRTPS wraps the whole message incl. ACKNACK; metadata ==
9699 // None → no submessage protection configured. secure_outbound_bytes
9700 // (transform_outbound) covers both cases correctly.
9701 if metadata == ProtectionLevel::None || rtps != ProtectionLevel::None || bytes.len() < 20 {
9702 return secure_outbound_bytes(rt, bytes);
9703 }
9704 let subs = walk_submessages(bytes);
9705 let mut out = Vec::with_capacity(bytes.len() + 64);
9706 out.extend_from_slice(&bytes[..20]);
9707 for (id, start, total) in subs {
9708 let submsg = &bytes[start..start + total];
9709 if is_protected_reader_submessage(id) {
9710 let handle = reader_eid_in_submessage(submsg, id)
9711 .and_then(|r| local_endpoint_crypto_handle(rt, r, false));
9712 match handle {
9713 Some(h) => match gate.encode_data_datawriter_by_handle(h, submsg) {
9714 Ok(s) => out.extend_from_slice(&s),
9715 Err(_) => return None,
9716 },
9717 // No per-endpoint reader key yet (the endpoint matches only after
9718 // secure SEDP) → pass through plaintext; the reader tick re-sends
9719 // the ACKNACK once the key is installed.
9720 None => out.extend_from_slice(submsg),
9721 }
9722 } else {
9723 // Framing submessage (INFO_DST/INFO_TS/...) — unchanged.
9724 out.extend_from_slice(submsg);
9725 }
9726 }
9727 Some(alloc::borrow::Cow::Owned(out))
9728}
9729
9730#[cfg(not(feature = "security"))]
9731fn protect_user_reader_datagram<'a>(
9732 rt: &DcpsRuntime,
9733 bytes: &'a [u8],
9734) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9735 secure_outbound_bytes(rt, bytes)
9736}
9737
9738/// `true` if `liveliness_protection != NONE` is configured — then WLP runs
9739/// over the secure entity + participant-key protection (§8.4.2.4).
9740#[cfg(feature = "security")]
9741fn wlp_liveliness_protected(rt: &DcpsRuntime) -> bool {
9742 rt.config.security.as_ref().is_some_and(|gate| {
9743 gate.liveliness_protection()
9744 .unwrap_or(ProtectionLevel::None)
9745 != ProtectionLevel::None
9746 })
9747}
9748
9749#[cfg(not(feature = "security"))]
9750fn wlp_liveliness_protected(_rt: &DcpsRuntime) -> bool {
9751 false
9752}
9753
9754/// Protects a WLP outbound datagram (BUILTIN_PARTICIPANT_MESSAGE_SECURE_WRITER
9755/// DATA) under `liveliness_protection != NONE` with the **participant data key**
9756/// (§8.4.2.4 / §7.4.7.1 Tab.7). WLP is participant-level (no per-endpoint key)
9757/// — analogous to the participant-key fallback in `protect_sedp_outbound`. If
9758/// `rtps_protection` already covers the message as SRTPS (or liveliness=NONE),
9759/// the function delegates to `secure_outbound_bytes`.
9760#[cfg(feature = "security")]
9761fn protect_wlp_outbound<'a>(
9762 rt: &DcpsRuntime,
9763 bytes: &'a [u8],
9764) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9765 let Some(gate) = rt.config.security.as_ref() else {
9766 return Some(alloc::borrow::Cow::Borrowed(bytes));
9767 };
9768 let live = gate
9769 .liveliness_protection()
9770 .unwrap_or(ProtectionLevel::None);
9771 let rtps = gate.rtps_protection().unwrap_or(ProtectionLevel::None);
9772 // liveliness=NONE: no inner SEC layer -> secure_outbound_bytes covers
9773 // rtps_protection (SRTPS) resp. passthrough. PREVIOUSLY this branch
9774 // also delegated with rtps!=None and thus left out the liveliness SEC -> cyclone
9775 // saw the WLP DATA "clear submsg from protected src" -> no liveliness.
9776 if live == ProtectionLevel::None || bytes.len() < 20 {
9777 return secure_outbound_bytes(rt, bytes);
9778 }
9779 let subs = walk_submessages(bytes);
9780 let mut out = Vec::with_capacity(bytes.len() + 64);
9781 out.extend_from_slice(&bytes[..20]);
9782 for (id, start, total) in subs {
9783 let submsg = &bytes[start..start + total];
9784 if id == SMID_DATA {
9785 // Protect the secure-WLP DATA with the per-endpoint key of the secure-WLP writer
9786 // (ff0200c2) — the same key ZeroDDS sends the peer via the
9787 // datawriter_crypto_token (prepare_endpoint_crypto_tokens
9788 // liveliness block). encode_data_datawriter_local took the participant
9789 // key, which cyclone does NOT associate with ff0200c2 -> undecodable ->
9790 // no liveliness -> peer approval of the user endpoints hangs.
9791 let sec = writer_eid_in_submessage(submsg, id)
9792 .and_then(|w| local_endpoint_crypto_handle(rt, w, true))
9793 .and_then(|h| gate.encode_data_datawriter_by_handle(h, submsg).ok());
9794 match sec {
9795 Some(s) => out.extend_from_slice(&s),
9796 None => return None,
9797 }
9798 } else {
9799 out.extend_from_slice(submsg);
9800 }
9801 }
9802 // Under additional rtps_protection, message-level SRTPS MUST go around the
9803 // liveliness-SEC-wrapped WLP (both layers, like cyclone<->cyclone) —
9804 // otherwise cyclone would see only the SRTPS shell OR (with the old logic) the
9805 // clear DATA. First inner SEC (above), then SRTPS (here).
9806 if rtps != ProtectionLevel::None {
9807 return gate
9808 .transform_outbound(&out)
9809 .ok()
9810 .map(alloc::borrow::Cow::Owned);
9811 }
9812 Some(alloc::borrow::Cow::Owned(out))
9813}
9814
9815#[cfg(not(feature = "security"))]
9816fn protect_wlp_outbound<'a>(
9817 rt: &DcpsRuntime,
9818 bytes: &'a [u8],
9819) -> Option<alloc::borrow::Cow<'a, [u8]>> {
9820 secure_outbound_bytes(rt, bytes)
9821}
9822
9823/// Wire demux for the security builtin topics. Routes an
9824/// incoming RTPS submessage sequence to the `SecurityBuiltinStack`,
9825/// if the stack is active. No-op if the datagram does not address a security
9826/// builtin reader or the plugin is not enabled.
9827///
9828/// Called by the metatraffic receive path — stateless +
9829/// VolatileSecure run over the SPDP unicast locators (PID 0x0032),
9830/// not over `user_unicast`.
9831fn dispatch_security_builtin_datagram(
9832 rt: &Arc<DcpsRuntime>,
9833 bytes: &[u8],
9834 now: Duration,
9835) -> Vec<zerodds_rtps::message_builder::OutboundDatagram> {
9836 // `mut` only needed on the security path (the handshake reply is appended
9837 // there); without the feature the list stays empty.
9838 #[cfg(feature = "security")]
9839 let mut outbound = Vec::new();
9840 #[cfg(not(feature = "security"))]
9841 let outbound = Vec::new();
9842 let Some(stack) = rt.security_builtin_snapshot() else {
9843 return outbound;
9844 };
9845 // Cross-vendor VolatileSecure: cyclone protects the volatile DATA as a
9846 // SEC_PREFIX/SEC_BODY/SEC_POSTFIX sequence. Before the submessage parse,
9847 // transform the sequence with the sender's Kx key (GuidPrefix = RTPS header bytes[8..20])
9848 // back to the original DATA submessage. `None` = no SEC_*
9849 // sequence (normal path) resp. crypto error.
9850 #[cfg(feature = "security")]
9851 let unprotected: Option<Vec<u8>> = if bytes.len() >= 20 {
9852 let mut pk = [0u8; 12];
9853 pk.copy_from_slice(&bytes[8..20]);
9854 unprotect_volatile_datagram(rt, bytes, &pk)
9855 } else {
9856 None
9857 };
9858 #[cfg(feature = "security")]
9859 let bytes: &[u8] = unprotected.as_deref().unwrap_or(bytes);
9860 let Ok(parsed) = decode_datagram(bytes) else {
9861 return outbound;
9862 };
9863 // sourceGuidPrefix of the datagram (DDSI-RTPS §8.3.4) — reader demux key for
9864 // the volatile builtin readers. Used in both feature configs.
9865 let remote_prefix = parsed.header.guid_prefix;
9866 let Ok(mut s) = stack.lock() else {
9867 return outbound;
9868 };
9869 for sub in parsed.submessages {
9870 match sub {
9871 ParsedSubmessage::Data(d) => {
9872 if d.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
9873 || d.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
9874 {
9875 // FU2 Gap 5: decode the stateless auth and — with
9876 // an active auth plugin — drive the handshake.
9877 // `on_stateless_message` returns the next token
9878 // message (reply/final), which we send back to the peer.
9879 // Decode errors are swallowed (stateless
9880 // has no resend path, Spec §10.3.4.1). The
9881 // completion `(remote_identity, secret)` is stored in the stack
9882 // (peer_secret) — the gate registration +
9883 // crypto-token exchange follows in Gap 6.
9884 if let Ok(msg) = s.stateless_reader.handle_data(&d) {
9885 #[cfg(feature = "security")]
9886 s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
9887 #[cfg(feature = "security")]
9888 if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, &msg) {
9889 outbound.extend(out);
9890 // FU2 S1.4: handshake done → register Kx +
9891 // send the Kx-encrypted data token to the peer over Volatile-
9892 // Secure. (the pki lock is free here:
9893 // on_stateless_message released it.)
9894 if let Some((remote_identity, secret)) = completed {
9895 if let Some(token_msg) =
9896 prepare_crypto_token(rt, remote_prefix, remote_identity, secret)
9897 {
9898 outbound.extend(protect_volatile_outbound(
9899 rt,
9900 remote_prefix,
9901 s.volatile_writer
9902 .write_with_heartbeat(&token_msg, now)
9903 .unwrap_or_default(),
9904 ));
9905 }
9906 // Step 6b: per-endpoint datawriter/datareader
9907 // tokens (per-token dedup #29: the builtins go out
9908 // here exactly once + are marked).
9909 let already = rt
9910 .endpoint_tokens_sent
9911 .read()
9912 .map(|set| set.clone())
9913 .unwrap_or_default();
9914 let pending = pending_endpoint_tokens(
9915 prepare_endpoint_crypto_tokens(rt, remote_prefix),
9916 &already,
9917 );
9918 for ep_msg in pending {
9919 let key = endpoint_token_key(&ep_msg);
9920 outbound.extend(protect_volatile_outbound(
9921 rt,
9922 remote_prefix,
9923 s.volatile_writer
9924 .write_with_heartbeat(&ep_msg, now)
9925 .unwrap_or_default(),
9926 ));
9927 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
9928 set.insert(key);
9929 }
9930 }
9931 }
9932 }
9933 #[cfg(not(feature = "security"))]
9934 let _ = msg;
9935 }
9936 } else if d.reader_id
9937 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
9938 {
9939 // FU2 S1.4: VolatileSecure carries the crypto-token
9940 // exchange. Kx-decrypt the received PARTICIPANT_CRYPTO_TOKENS
9941 // message + install the data key in the gate.
9942 if let Ok(_msgs) = s.volatile_reader.handle_data(remote_prefix, &d) {
9943 #[cfg(feature = "security")]
9944 for m in &_msgs {
9945 install_crypto_token(rt, remote_prefix, m);
9946 }
9947 // Step 6b: now (peer ready) send our per-endpoint
9948 // tokens back. Per-token dedup (#29): builtins
9949 // go out early here, the later-matching user-
9950 // endpoint tokens are caught up by the tick path (no per-peer
9951 // guard that blocks them forever).
9952 #[cfg(feature = "security")]
9953 {
9954 let already = rt
9955 .endpoint_tokens_sent
9956 .read()
9957 .map(|set| set.clone())
9958 .unwrap_or_default();
9959 let pending = pending_endpoint_tokens(
9960 prepare_endpoint_crypto_tokens(rt, remote_prefix),
9961 &already,
9962 );
9963 for ep_msg in pending {
9964 let key = endpoint_token_key(&ep_msg);
9965 outbound.extend(protect_volatile_outbound(
9966 rt,
9967 remote_prefix,
9968 s.volatile_writer
9969 .write_with_heartbeat(&ep_msg, now)
9970 .unwrap_or_default(),
9971 ));
9972 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
9973 set.insert(key);
9974 }
9975 }
9976 }
9977 // The peer now has our participant crypto token (can
9978 // decode our SRTPS/SEC SEDP): catch up the initially dropped
9979 // SEDP burst once (OpenDDS convergence).
9980 #[cfg(feature = "security")]
9981 rt.re_announce_sedp_to_peer(remote_prefix);
9982 }
9983 }
9984 }
9985 ParsedSubmessage::DataFrag(df) => {
9986 if df.reader_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_READER
9987 || df.writer_id == EntityId::BUILTIN_PARTICIPANT_STATELESS_MESSAGE_WRITER
9988 {
9989 // FU2 cross-vendor: cyclone/FastDDS RTPS-fragment the
9990 // large HandshakeReply/Final (cert + permissions over
9991 // MTU). Reassemble the fragments + drive them through the
9992 // handshake driver like a stateless DATA.
9993 if let Ok(msgs) = s.stateless_reader.handle_data_frag(&df) {
9994 #[cfg(feature = "security")]
9995 s.note_remote_vendor(remote_prefix, parsed.header.vendor_id);
9996 #[cfg(feature = "security")]
9997 for msg in &msgs {
9998 if let Ok((out, completed)) = s.on_stateless_message(remote_prefix, msg)
9999 {
10000 outbound.extend(out);
10001 if let Some((remote_identity, secret)) = completed {
10002 if let Some(token_msg) = prepare_crypto_token(
10003 rt,
10004 remote_prefix,
10005 remote_identity,
10006 secret,
10007 ) {
10008 outbound.extend(protect_volatile_outbound(
10009 rt,
10010 remote_prefix,
10011 s.volatile_writer
10012 .write_with_heartbeat(&token_msg, now)
10013 .unwrap_or_default(),
10014 ));
10015 }
10016 let already = rt
10017 .endpoint_tokens_sent
10018 .read()
10019 .map(|set| set.clone())
10020 .unwrap_or_default();
10021 let pending = pending_endpoint_tokens(
10022 prepare_endpoint_crypto_tokens(rt, remote_prefix),
10023 &already,
10024 );
10025 for ep_msg in pending {
10026 let key = endpoint_token_key(&ep_msg);
10027 outbound.extend(protect_volatile_outbound(
10028 rt,
10029 remote_prefix,
10030 s.volatile_writer
10031 .write_with_heartbeat(&ep_msg, now)
10032 .unwrap_or_default(),
10033 ));
10034 if let Ok(mut set) = rt.endpoint_tokens_sent.write() {
10035 set.insert(key);
10036 }
10037 }
10038 }
10039 }
10040 }
10041 #[cfg(not(feature = "security"))]
10042 let _ = msgs;
10043 }
10044 } else if df.reader_id
10045 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10046 {
10047 let _ = s.volatile_reader.handle_data_frag(remote_prefix, &df, now);
10048 }
10049 }
10050 ParsedSubmessage::Heartbeat(h) => {
10051 let to_volatile_reader = h.reader_id
10052 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER
10053 || (h.reader_id == EntityId::UNKNOWN
10054 && h.writer_id
10055 == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER);
10056 if to_volatile_reader {
10057 s.volatile_reader.handle_heartbeat(remote_prefix, &h, now);
10058 }
10059 }
10060 ParsedSubmessage::Gap(g) => {
10061 if g.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
10062 let _ = s.volatile_reader.handle_gap(remote_prefix, &g);
10063 }
10064 }
10065 ParsedSubmessage::AckNack(ack) => {
10066 if ack.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
10067 let base = ack.reader_sn_state.bitmap_base;
10068 let requested: Vec<_> = ack.reader_sn_state.iter_set().collect();
10069 let src = Guid::new(parsed.header.guid_prefix, ack.reader_id);
10070 s.volatile_writer.handle_acknack(src, base, requested);
10071 }
10072 }
10073 ParsedSubmessage::NackFrag(nf) => {
10074 if nf.writer_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER {
10075 let src = Guid::new(parsed.header.guid_prefix, nf.reader_id);
10076 s.volatile_writer.handle_nackfrag(src, &nf);
10077 }
10078 }
10079 _ => {}
10080 }
10081 }
10082 outbound
10083}
10084
10085/// Dispatches a datagram addressed to the TypeLookup service endpoints
10086/// (XTypes 1.3 §7.6.3.3.4). Handles incoming
10087/// requests (to `TL_SVC_REQ_READER`), generates replies and sends
10088/// them back to the source locator; handles incoming replies
10089/// (to `TL_SVC_REPLY_READER`), correlates with the client.
10090///
10091/// Returns `true` if the datagram was accepted by the TypeLookup path
10092/// — the caller can then skip the user-reader path.
10093fn dispatch_type_lookup_datagram(rt: &Arc<DcpsRuntime>, bytes: &[u8], source: &Locator) -> bool {
10094 use zerodds_cdr::{BufferReader, Endianness};
10095 use zerodds_rtps::inline_qos::{SampleIdentityBytes, find_related_sample_identity};
10096 use zerodds_types::type_lookup::{
10097 GetTypeDependenciesReply, GetTypeDependenciesRequest, GetTypesReply, GetTypesRequest,
10098 };
10099
10100 let Ok(parsed) = decode_datagram(bytes) else {
10101 return false;
10102 };
10103 // DDS-RPC §7.8.2: the request sample identity = (request writer GUID,
10104 // request SN). The server carries it as PID_RELATED_SAMPLE_IDENTITY in the
10105 // reply inline QoS, so a client (also cross-vendor) can correlate
10106 // without relying on the echoed writer_sn.
10107 let src_prefix = parsed.header.guid_prefix;
10108
10109 let mut accepted = false;
10110
10111 for sub in &parsed.submessages {
10112 let ParsedSubmessage::Data(d) = sub else {
10113 continue;
10114 };
10115 let payload: &[u8] = &d.serialized_payload;
10116 if payload.is_empty() {
10117 continue;
10118 }
10119 // Skip CDR-Encapsulation header (4 bytes) if present.
10120 let body: &[u8] = if payload.len() >= 4 && (payload[0] == 0x00 && payload[1] == 0x01) {
10121 &payload[4..]
10122 } else {
10123 payload
10124 };
10125
10126 // Inbound Request → Server.
10127 if d.reader_id == EntityId::TL_SVC_REQ_READER {
10128 accepted = true;
10129 // Request sample identity = (request writer GUID, request SN) — mirrored
10130 // as related_sample_identity into the reply inline QoS.
10131 let (sn_hi, sn_lo) = d.writer_sn.split();
10132 let req_sn = ((u64::from(sn_hi as u32)) << 32) | u64::from(sn_lo);
10133 let related =
10134 SampleIdentityBytes::new(Guid::new(src_prefix, d.writer_id).to_bytes(), req_sn);
10135 // Try GetTypes-Request first; fall back to
10136 // GetTypeDependenciesRequest if that fails.
10137 let mut r = BufferReader::new(body, Endianness::Little);
10138 if let Ok(req) = GetTypesRequest::decode_from(&mut r) {
10139 let reply = match rt.type_lookup_server.lock() {
10140 Ok(g) => g.handle_get_types(&req),
10141 Err(_) => continue,
10142 };
10143 let _ = send_type_lookup_reply(
10144 rt,
10145 source,
10146 TypeLookupReplyPayload::Types(reply),
10147 related,
10148 );
10149 continue;
10150 }
10151 let mut r = BufferReader::new(body, Endianness::Little);
10152 if let Ok(req) = GetTypeDependenciesRequest::decode_from(&mut r) {
10153 let reply = match rt.type_lookup_server.lock() {
10154 Ok(g) => g.handle_get_type_dependencies(&req),
10155 Err(_) => continue,
10156 };
10157 let _ = send_type_lookup_reply(
10158 rt,
10159 source,
10160 TypeLookupReplyPayload::Dependencies(reply),
10161 related,
10162 );
10163 continue;
10164 }
10165 }
10166
10167 // Inbound Reply → Client.
10168 if d.reader_id == EntityId::TL_SVC_REPLY_READER {
10169 accepted = true;
10170 // Correlation prefers PID_RELATED_SAMPLE_IDENTITY (DDS-RPC §7.8.2,
10171 // cross-vendor compatible); fallback to the echoed writer_sn for
10172 // peers/legacy replies without inline QoS.
10173 let request_id = d
10174 .inline_qos
10175 .as_ref()
10176 .and_then(|pl| find_related_sample_identity(pl, true).ok().flatten())
10177 .map(|sid| zerodds_discovery::type_lookup::RequestId::from_u64(sid.sequence_number))
10178 .unwrap_or_else(|| {
10179 let (sn_high, sn_low) = d.writer_sn.split();
10180 let sn_u64 = ((u64::from(sn_high as u32)) << 32) | u64::from(sn_low);
10181 zerodds_discovery::type_lookup::RequestId::from_u64(sn_u64)
10182 });
10183 let mut r = BufferReader::new(body, Endianness::Little);
10184 if let Ok(reply) = GetTypesReply::decode_from(&mut r) {
10185 if let Ok(mut client) = rt.type_lookup_client.lock() {
10186 client.handle_reply(request_id, TypeLookupReply::Types(reply));
10187 }
10188 continue;
10189 }
10190 // M-5: the getTypeDependencies reply carries a different element type
10191 // (TypeIdentifierWithSize list) — its own decode branch, otherwise the
10192 // dependencies callback never fires.
10193 let mut r = BufferReader::new(body, Endianness::Little);
10194 if let Ok(reply) = GetTypeDependenciesReply::decode_from(&mut r) {
10195 if let Ok(mut client) = rt.type_lookup_client.lock() {
10196 client.handle_reply(request_id, TypeLookupReply::Dependencies(reply));
10197 }
10198 continue;
10199 }
10200 }
10201 }
10202
10203 accepted
10204}
10205
10206/// Reply payload variants that the TypeLookup server can emit.
10207enum TypeLookupReplyPayload {
10208 Types(zerodds_types::type_lookup::GetTypesReply),
10209 Dependencies(zerodds_types::type_lookup::GetTypeDependenciesReply),
10210}
10211
10212/// Sends a TypeLookup reply to a peer locator as a
10213/// DATA datagram on the TL_SVC_REPLY_WRITER → peer's
10214/// TL_SVC_REPLY_READER. The sequence number echoes the request sequence
10215/// for correlation purposes (see XTypes §7.6.3.3.3 sample identity).
10216fn send_type_lookup_reply(
10217 rt: &Arc<DcpsRuntime>,
10218 target: &Locator,
10219 reply: TypeLookupReplyPayload,
10220 related: zerodds_rtps::inline_qos::SampleIdentityBytes,
10221) -> Result<()> {
10222 use alloc::sync::Arc as AllocArc;
10223 use core::sync::atomic::Ordering;
10224 use zerodds_cdr::{BufferWriter, Endianness};
10225 use zerodds_rtps::datagram::encode_data_datagram;
10226 use zerodds_rtps::header::RtpsHeader;
10227 use zerodds_rtps::submessages::DataSubmessage;
10228 use zerodds_rtps::wire_types::{ProtocolVersion, SequenceNumber, VendorId};
10229
10230 // CDR-encode reply (PL_CDR_LE-Encapsulation).
10231 let mut w = BufferWriter::new(Endianness::Little);
10232 match reply {
10233 TypeLookupReplyPayload::Types(r) => {
10234 r.encode_into(&mut w)
10235 .map_err(|_| DdsError::PreconditionNotMet {
10236 reason: "type_lookup reply encode failed",
10237 })?;
10238 }
10239 TypeLookupReplyPayload::Dependencies(r) => {
10240 r.encode_into(&mut w)
10241 .map_err(|_| DdsError::PreconditionNotMet {
10242 reason: "type_lookup deps reply encode failed",
10243 })?;
10244 }
10245 }
10246 let body = w.into_bytes();
10247 let mut payload: alloc::vec::Vec<u8> = alloc::vec::Vec::with_capacity(4 + body.len());
10248 payload.extend_from_slice(&[0x00, 0x01, 0x00, 0x00]);
10249 payload.extend_from_slice(&body);
10250
10251 let header = RtpsHeader {
10252 protocol_version: ProtocolVersion::CURRENT,
10253 vendor_id: VendorId::ZERODDS,
10254 guid_prefix: rt.guid_prefix,
10255 };
10256 // Own monotonically increasing reply-writer SN (starting at 1) instead of a
10257 // request-SN echo — a reliable cross-vendor reply reader would otherwise see SN jumps.
10258 let reply_sn = rt
10259 .tl_reply_sn
10260 .fetch_add(1, Ordering::Relaxed)
10261 .wrapping_add(1);
10262 let writer_sn =
10263 SequenceNumber::from_high_low((reply_sn >> 32) as i32, (reply_sn & 0xFFFF_FFFF) as u32);
10264 let data = DataSubmessage {
10265 extra_flags: 0,
10266 reader_id: EntityId::TL_SVC_REPLY_READER,
10267 writer_id: EntityId::TL_SVC_REPLY_WRITER,
10268 writer_sn,
10269 // DDS-RPC §7.8.2: related_sample_identity couples the reply to the
10270 // request (cross-vendor correlation without a writer_sn echo).
10271 inline_qos: Some(zerodds_rtps::inline_qos::reply_inline_qos(related, true)),
10272 key_flag: false,
10273 non_standard_flag: false,
10274 serialized_payload: AllocArc::from(payload.into_boxed_slice()),
10275 };
10276 let datagram =
10277 encode_data_datagram(header, &[data]).map_err(|_| DdsError::PreconditionNotMet {
10278 reason: "type_lookup reply datagram encode failed",
10279 })?;
10280
10281 if is_routable_user_locator(target) {
10282 let _ = rt.user_unicast.send(target, &datagram);
10283 }
10284 Ok(())
10285}
10286
10287/// Sends a discovery datagram to all target locators. UDP-only
10288/// (TCPv4/SHM/UDS are not carried in discovery); non-UDP
10289/// locators are silently ignored.
10290fn send_discovery_datagram(rt: &Arc<DcpsRuntime>, targets: &[Locator], bytes: &[u8]) {
10291 let Some(secured) = secure_outbound_bytes(rt, bytes) else {
10292 return;
10293 };
10294 for t in targets {
10295 if !is_routable_user_locator(t) {
10296 continue;
10297 }
10298 // Send unicast metatraffic (SEDP responses, VolatileSecure, stateless auth)
10299 // from the **metatraffic recv socket** (`spdp_unicast`, = announced
10300 // metatraffic_unicast_locator), NOT from the ephemeral `spdp_mc_tx`.
10301 // Otherwise the peer sees a foreign source port and sends its
10302 // responses (e.g. cyclone's VolatileSecure ACKNACK to the source locator)
10303 // to a port ZeroDDS does not listen on → reliable resends stay
10304 // out (cross-vendor). `spdp_mc_tx` stays only for SPDP multicast.
10305 let _ = rt.spdp_unicast.send(t, &secured);
10306 }
10307}
10308
10309/// Default user-multicast locator for a DomainParticipant.
10310/// Not used in live mode 1 yet; SPDP-announced in B2.
10311#[must_use]
10312pub fn user_multicast_endpoint(domain_id: i32) -> SocketAddr {
10313 // Spec §9.6.1.4.1: user-multicast-port = PB + DG * d + d2
10314 // = 7400 + 250 * d + 1
10315 let port = 7400u16.saturating_add(250u16.saturating_mul(domain_id as u16).saturating_add(1));
10316 SocketAddr::from((Ipv4Addr::from([239, 255, 0, 1]), port))
10317}
10318
10319#[cfg(test)]
10320#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
10321mod tests {
10322 use super::*;
10323
10324 /// FU1 diagnosis: inject a REAL FastDDS-3.6 SPDP datagram (domain 205,
10325 /// codepit capture 2026-05-29) directly into handle_spdp_datagram
10326 /// — does the runtime register FastDDS as a peer? Separates the
10327 /// receive problem (socket) from the handle problem (parse/insert/filter).
10328 #[test]
10329 fn handle_spdp_registers_real_fastdds_participant() {
10330 fn hx(s: &str) -> Vec<u8> {
10331 (0..s.len())
10332 .step_by(2)
10333 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
10334 .collect()
10335 }
10336 const FASTDDS_SPDP: &str = "525450530203010f010fa72bbbebc90100000000090108006850196a57e882b41505b80100001000000100c7000100c2000000000100000000030000150004000203000016000400010f000000800400030601000f000400cd00000050001000010fa72bbbebc90100000000000001c107800400010000000380280021000000653830653630353335646339343432636231306537323239313038653135666600000000320018000100000024e50000000000000000000000000000c0a8b273310018000100000025e50000000000000000000000000000c0a8b273020008001400000000000000580004003ffc0f006200140010000000525450535061727469636970616e74005900cc0004000000110000005041525449434950414e545f54595045000000000700000053494d504c4500001b000000666173746464732e706879736963616c5f646174612e686f73740000210000006538306536303533356463393434326362313065373232393130386531356666000000001b000000666173746464732e706879736963616c5f646174612e75736572000005000000726f6f74000000001e000000666173746464732e706879736963616c5f646174612e70726f636573730000000600000036303334370000000100000080013800010000001ae50000000000000000000000000000efff00016850196a72ca8cb4020000000000000030040000000000000000000000000000";
10337 let bytes = hx(FASTDDS_SPDP);
10338 let prefix = GuidPrefix::from_bytes([0x99; 12]);
10339 let rt =
10340 Arc::new(DcpsRuntime::start(205, prefix, RuntimeConfig::default()).expect("rt start"));
10341 assert_eq!(rt.discovered_participants().len(), 0, "fresh: no peers");
10342 handle_spdp_datagram_for_test(&rt, &bytes);
10343 let n = rt.discovered_participants().len();
10344 assert_eq!(
10345 n, 1,
10346 "FastDDS must be registered after handle_spdp_datagram (got {n})"
10347 );
10348 }
10349
10350 #[test]
10351 fn select_user_transport_tcpv4_yields_tcpv4_locator() {
10352 let prefix = GuidPrefix::from_bytes([1u8; 12]);
10353 let (t, accept) =
10354 select_user_transport(UserTransportKind::TcpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
10355 .expect("TcpV4 transport");
10356 assert_eq!(t.local_locator().kind, LocatorKind::Tcpv4);
10357 assert!(accept.is_some(), "TCP needs an accept handle");
10358 }
10359
10360 #[test]
10361 fn select_user_transport_udpv4_default_kind() {
10362 let prefix = GuidPrefix::from_bytes([2u8; 12]);
10363 let (t, accept) =
10364 select_user_transport(UserTransportKind::UdpV4, prefix, 0, Ipv4Addr::UNSPECIFIED)
10365 .expect("UdpV4 transport");
10366 assert_eq!(t.local_locator().kind, LocatorKind::UdpV4);
10367 assert!(accept.is_none(), "UDP needs no accept handle");
10368 }
10369
10370 #[cfg(feature = "same-host-uds")]
10371 #[test]
10372 fn select_user_transport_uds_yields_uds_locator() {
10373 let prefix = GuidPrefix::from_bytes([3u8; 12]);
10374 let (t, accept) =
10375 select_user_transport(UserTransportKind::Uds, prefix, 0, Ipv4Addr::UNSPECIFIED)
10376 .expect("Uds transport");
10377 assert_eq!(t.local_locator().kind, LocatorKind::Uds);
10378 assert!(accept.is_none(), "UDS needs no accept handle");
10379 }
10380
10381 #[test]
10382 fn strip_user_encap_xcdr2_le() {
10383 let payload = [0x00, 0x07, 0x00, 0x00, 1, 2, 3];
10384 assert_eq!(strip_user_encap(&payload), Some(alloc::vec![1, 2, 3]));
10385 }
10386
10387 #[test]
10388 fn strip_user_encap_xcdr1_le() {
10389 // Cyclone default for simple types.
10390 let payload = [0x00, 0x01, 0x00, 0x00, 0xAA];
10391 assert_eq!(strip_user_encap(&payload), Some(alloc::vec![0xAA]));
10392 }
10393
10394 #[test]
10395 fn strip_user_encap_rejects_unknown_scheme() {
10396 let payload = [0xFF, 0xFF, 0x00, 0x00, 1];
10397 assert_eq!(strip_user_encap(&payload), None);
10398 }
10399
10400 #[test]
10401 fn strip_user_encap_rejects_short() {
10402 assert_eq!(strip_user_encap(&[0x00, 0x07]), None);
10403 }
10404
10405 #[test]
10406 fn user_payload_encap_is_cdr_le() {
10407 // CDR_LE (PLAIN_CDR / XCDR1, Little-Endian) — ehrliche
10408 // Declaration of the body encoding generated by codegen.
10409 assert_eq!(USER_PAYLOAD_ENCAP, [0x00, 0x01, 0x00, 0x00]);
10410 }
10411
10412 #[test]
10413 fn data_repr_offer_str_uses_spec_ids() {
10414 use zerodds_rtps::publication_data::data_representation as dr;
10415 // XCDR1 -> Spec-Id 0 (NICHT 1 = XML); XCDR2 -> 2.
10416 assert_eq!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XCDR]));
10417 assert_eq!(parse_data_repr_offer_str("XCDR2"), Some(vec![dr::XCDR2]));
10418 assert_eq!(parse_data_repr_offer_str("xcdr2"), Some(vec![dr::XCDR2]));
10419 assert_eq!(
10420 parse_data_repr_offer_str("XCDR2,XCDR1"),
10421 Some(vec![dr::XCDR2, dr::XCDR])
10422 );
10423 assert_eq!(parse_data_repr_offer_str("bogus"), None);
10424 assert_eq!(parse_data_repr_offer_str(""), None);
10425 // XCDR1 must NOT map to the XML id (1).
10426 assert_ne!(parse_data_repr_offer_str("XCDR1"), Some(vec![dr::XML]));
10427 }
10428
10429 #[test]
10430 fn user_payload_encap_maps_repr_and_extensibility() {
10431 use zerodds_rtps::publication_data::data_representation as dr;
10432 use zerodds_types::qos::ExtensibilityForRepr as Ext;
10433 // DDSI-RTPS 2.5 §10.5 / XTypes 1.3 Tab.59 Encapsulation-IDs
10434 // (2-byte repr-id BE + 2-byte options=0), little-endian variant:
10435 // XCDR1 final/appendable -> CDR_LE 0x0001
10436 // XCDR1 mutable -> PL_CDR_LE 0x0003
10437 // XCDR2 final -> PLAIN_CDR2_LE 0x0007
10438 // XCDR2 appendable -> D_CDR2_LE 0x0009
10439 // XCDR2 mutable -> PL_CDR2_LE 0x000b
10440 assert_eq!(
10441 user_payload_encap(dr::XCDR, Ext::Final),
10442 [0x00, 0x01, 0x00, 0x00]
10443 );
10444 assert_eq!(
10445 user_payload_encap(dr::XCDR, Ext::Appendable),
10446 [0x00, 0x01, 0x00, 0x00]
10447 );
10448 assert_eq!(
10449 user_payload_encap(dr::XCDR, Ext::Mutable),
10450 [0x00, 0x03, 0x00, 0x00]
10451 );
10452 assert_eq!(
10453 user_payload_encap(dr::XCDR2, Ext::Final),
10454 [0x00, 0x07, 0x00, 0x00]
10455 );
10456 assert_eq!(
10457 user_payload_encap(dr::XCDR2, Ext::Appendable),
10458 [0x00, 0x09, 0x00, 0x00]
10459 );
10460 assert_eq!(
10461 user_payload_encap(dr::XCDR2, Ext::Mutable),
10462 [0x00, 0x0b, 0x00, 0x00]
10463 );
10464 // The default const is exactly the (XCDR1, Final) case.
10465 assert_eq!(user_payload_encap(dr::XCDR, Ext::Final), USER_PAYLOAD_ENCAP);
10466 // Unknown/XML repr falls back safely to CDR_LE.
10467 assert_eq!(
10468 user_payload_encap(dr::XML, Ext::Final),
10469 [0x00, 0x01, 0x00, 0x00]
10470 );
10471 }
10472
10473 #[test]
10474 fn observability_sink_records_writer_and_reader_creation() {
10475 // VecSink injizieren, Writer + Reader erzeugen,
10476 // check that both events arrive.
10477 use std::sync::Arc as StdArc;
10478 use zerodds_foundation::observability::{Component, Level, VecSink};
10479
10480 let sink = StdArc::new(VecSink::new());
10481 let cfg = RuntimeConfig {
10482 observability: sink.clone(),
10483 ..RuntimeConfig::default()
10484 };
10485 let rt =
10486 DcpsRuntime::start(7, GuidPrefix::from_bytes([0xAA; 12]), cfg).expect("start runtime");
10487 let _ = rt.register_user_writer(UserWriterConfig {
10488 topic_name: "ObsTopic".into(),
10489 type_name: "ObsType".into(),
10490 reliable: true,
10491 durability: zerodds_qos::DurabilityKind::Volatile,
10492 deadline: zerodds_qos::DeadlineQosPolicy::default(),
10493 lifespan: zerodds_qos::LifespanQosPolicy::default(),
10494 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10495 ownership: zerodds_qos::OwnershipKind::Shared,
10496 ownership_strength: 0,
10497 partition: alloc::vec![],
10498 user_data: alloc::vec![],
10499 topic_data: alloc::vec![],
10500 group_data: alloc::vec![],
10501 type_identifier: zerodds_types::TypeIdentifier::None,
10502 data_representation_offer: None,
10503 });
10504 let _ = rt.register_user_reader(UserReaderConfig {
10505 topic_name: "ObsTopic".into(),
10506 type_name: "ObsType".into(),
10507 reliable: true,
10508 durability: zerodds_qos::DurabilityKind::Volatile,
10509 deadline: zerodds_qos::DeadlineQosPolicy::default(),
10510 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10511 ownership: zerodds_qos::OwnershipKind::Shared,
10512 partition: alloc::vec![],
10513 user_data: alloc::vec![],
10514 topic_data: alloc::vec![],
10515 group_data: alloc::vec![],
10516 type_identifier: zerodds_types::TypeIdentifier::None,
10517 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
10518 data_representation_offer: None,
10519 });
10520 rt.shutdown();
10521
10522 let events = sink.snapshot();
10523 assert!(
10524 events.iter().any(|e| e.name == "user_writer.created"
10525 && e.component == Component::Dcps
10526 && e.level == Level::Info),
10527 "writer-event missing: got {:?}",
10528 events.iter().map(|e| e.name).collect::<Vec<_>>()
10529 );
10530 assert!(
10531 events
10532 .iter()
10533 .any(|e| e.name == "user_reader.created" && e.component == Component::Dcps),
10534 "reader-event missing"
10535 );
10536 // The topic attribute must hang on the writer.created event.
10537 let writer_event = events
10538 .iter()
10539 .find(|e| e.name == "user_writer.created")
10540 .expect("writer event");
10541 assert!(
10542 writer_event
10543 .attrs
10544 .iter()
10545 .any(|a| a.key == "topic" && a.value == "ObsTopic"),
10546 "topic attr missing"
10547 );
10548 }
10549
10550 #[test]
10551 fn user_endpoint_entity_kind_follows_keyedness() {
10552 // Regression (ROS-2 cross-vendor): the entityKind of a user
10553 // endpoint MUST follow the type keyedness (Spec §9.3.1.2). A
10554 // a keyless type yields NoKey (Writer 0x03 / Reader 0x04), a
10555 // keyed type WithKey (0x02 / 0x07). If this does not match the
10556 // peer, CycloneDDS/ROS 2 silently rejects the endpoint match
10557 // (DDS_INVALID_QOS_POLICY_ID, no log). create_datawriter/
10558 // create_datareader derive `is_keyed` from `DdsType::HAS_KEY`.
10559 use zerodds_rtps::wire_types::EntityKind;
10560 let rt = DcpsRuntime::start(
10561 11,
10562 GuidPrefix::from_bytes([0xBC; 12]),
10563 RuntimeConfig::default(),
10564 )
10565 .expect("start runtime");
10566 let mk_w = || UserWriterConfig {
10567 topic_name: "KindTopic".into(),
10568 type_name: "KindType".into(),
10569 reliable: true,
10570 durability: zerodds_qos::DurabilityKind::Volatile,
10571 deadline: zerodds_qos::DeadlineQosPolicy::default(),
10572 lifespan: zerodds_qos::LifespanQosPolicy::default(),
10573 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10574 ownership: zerodds_qos::OwnershipKind::Shared,
10575 ownership_strength: 0,
10576 partition: alloc::vec![],
10577 user_data: alloc::vec![],
10578 topic_data: alloc::vec![],
10579 group_data: alloc::vec![],
10580 type_identifier: zerodds_types::TypeIdentifier::None,
10581 data_representation_offer: None,
10582 };
10583 let mk_r = || UserReaderConfig {
10584 topic_name: "KindTopic".into(),
10585 type_name: "KindType".into(),
10586 reliable: true,
10587 durability: zerodds_qos::DurabilityKind::Volatile,
10588 deadline: zerodds_qos::DeadlineQosPolicy::default(),
10589 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10590 ownership: zerodds_qos::OwnershipKind::Shared,
10591 partition: alloc::vec![],
10592 user_data: alloc::vec![],
10593 topic_data: alloc::vec![],
10594 group_data: alloc::vec![],
10595 type_identifier: zerodds_types::TypeIdentifier::None,
10596 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
10597 data_representation_offer: None,
10598 };
10599 // keyless (HAS_KEY=false) -> NoKey
10600 let w_nokey = rt.register_user_writer_kind(mk_w(), false).expect("writer");
10601 assert_eq!(w_nokey.entity_kind, EntityKind::UserWriterNoKey);
10602 let (r_nokey, _) = rt.register_user_reader_kind(mk_r(), false).expect("reader");
10603 assert_eq!(r_nokey.entity_kind, EntityKind::UserReaderNoKey);
10604 // keyed (HAS_KEY=true) -> WithKey
10605 let w_key = rt.register_user_writer_kind(mk_w(), true).expect("writer");
10606 assert_eq!(w_key.entity_kind, EntityKind::UserWriterWithKey);
10607 let (r_key, _) = rt.register_user_reader_kind(mk_r(), true).expect("reader");
10608 assert_eq!(r_key.entity_kind, EntityKind::UserReaderWithKey);
10609 rt.shutdown();
10610 }
10611
10612 #[test]
10613 fn incompatible_qos_match_emits_loud_warning() {
10614 // C2 "loud instead of silent": an incompatible QoS match is logged as a
10615 // warn event with topic + policy, not silently discarded.
10616 // Setup: writer Volatile + reader TransientLocal on the same
10617 // Topic (reader requests more durability than the writer offers)
10618 // → intra-runtime match fails with policy DURABILITY.
10619 use std::sync::Arc as StdArc;
10620 use zerodds_foundation::observability::{Component, Level, VecSink};
10621
10622 let sink = StdArc::new(VecSink::new());
10623 let cfg_a = RuntimeConfig {
10624 observability: sink.clone(),
10625 tick_period: Duration::from_millis(5),
10626 ..RuntimeConfig::default()
10627 };
10628 let cfg_b = RuntimeConfig {
10629 tick_period: Duration::from_millis(5),
10630 ..RuntimeConfig::default()
10631 };
10632 // Two same-process runtimes, same domain → inproc discovery.
10633 let rt = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCE; 12]), cfg_a)
10634 .expect("start runtime a");
10635 let rt_b = DcpsRuntime::start(13, GuidPrefix::from_bytes([0xCF; 12]), cfg_b)
10636 .expect("start runtime b");
10637 let _w = rt
10638 .register_user_writer(UserWriterConfig {
10639 topic_name: "QT".into(),
10640 type_name: "QType".into(),
10641 reliable: false,
10642 durability: zerodds_qos::DurabilityKind::Volatile,
10643 deadline: zerodds_qos::DeadlineQosPolicy::default(),
10644 lifespan: zerodds_qos::LifespanQosPolicy::default(),
10645 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10646 ownership: zerodds_qos::OwnershipKind::Shared,
10647 ownership_strength: 0,
10648 partition: alloc::vec![],
10649 user_data: alloc::vec![],
10650 topic_data: alloc::vec![],
10651 group_data: alloc::vec![],
10652 type_identifier: zerodds_types::TypeIdentifier::None,
10653 data_representation_offer: None,
10654 })
10655 .expect("writer");
10656 let _r = rt_b
10657 .register_user_reader(UserReaderConfig {
10658 topic_name: "QT".into(),
10659 type_name: "QType".into(),
10660 reliable: false,
10661 durability: zerodds_qos::DurabilityKind::TransientLocal,
10662 deadline: zerodds_qos::DeadlineQosPolicy::default(),
10663 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10664 ownership: zerodds_qos::OwnershipKind::Shared,
10665 partition: alloc::vec![],
10666 user_data: alloc::vec![],
10667 topic_data: alloc::vec![],
10668 group_data: alloc::vec![],
10669 type_identifier: zerodds_types::TypeIdentifier::None,
10670 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
10671 data_representation_offer: None,
10672 })
10673 .expect("reader");
10674 // Await the match pass.
10675 let mut found = false;
10676 for _ in 0..40 {
10677 std::thread::sleep(Duration::from_millis(25));
10678 let events = sink.snapshot();
10679 if events.iter().any(|e| {
10680 (e.name == "qos.incompatible.offered" || e.name == "qos.incompatible.requested")
10681 && e.component == Component::Dcps
10682 && e.level == Level::Warn
10683 && e.attrs.iter().any(|a| a.key == "topic" && a.value == "QT")
10684 && e.attrs
10685 .iter()
10686 .any(|a| a.key == "policy" && a.value == "DURABILITY")
10687 }) {
10688 found = true;
10689 break;
10690 }
10691 }
10692 rt.shutdown();
10693 rt_b.shutdown();
10694 assert!(
10695 found,
10696 "expected a loud qos.incompatible warn event with policy DURABILITY"
10697 );
10698 }
10699
10700 #[test]
10701 fn spdp_unicast_port_follows_rtps_formula() {
10702 // Spec §9.6.1.4.1: PB + DG*domain + d1 + PG*pid = 7400+250*d+10+2*pid.
10703 assert_eq!(super::spdp_unicast_port(0, 0), 7410);
10704 assert_eq!(spdp_unicast_port(0, 1), 7412);
10705 assert_eq!(spdp_unicast_port(1, 0), 7660);
10706 assert_eq!(spdp_unicast_port(7, 0), 9160);
10707 }
10708
10709 #[test]
10710 fn announce_locator_pins_interface_over_route_probe() {
10711 // Interface pinning: a set interface takes precedence over the
10712 // route probe (multi-homed robustness, cf. Cyclone NetworkInterface).
10713 let udp = UdpTransport::bind_v4(Ipv4Addr::UNSPECIFIED, 0).expect("bind");
10714 let pin = Ipv4Addr::new(10, 11, 12, 13);
10715 let loc = super::announce_locator(&udp, pin);
10716 assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
10717 assert_eq!(loc.address[12..], [10, 11, 12, 13]);
10718 // Without a pin (UNSPECIFIED) → probe/fallback does NOT return the pin IP.
10719 let auto = super::announce_locator(&udp, Ipv4Addr::UNSPECIFIED);
10720 assert_ne!(auto.address[12..], [10, 11, 12, 13]);
10721 }
10722
10723 #[test]
10724 fn expand_initial_peer_ip_only_yields_well_known_port_range() {
10725 let m = super::INITIAL_PEER_MAX_PARTICIPANTS;
10726 let mut out = Vec::new();
10727 super::expand_initial_peer("127.0.0.1", 0, m, &mut out);
10728 assert_eq!(out.len(), m as usize);
10729 assert_eq!(out[0].port, 7410);
10730 assert_eq!(out[1].port, 7412);
10731 // Larger limit → more ports (C1 dense multi-robot scenarios).
10732 let mut wide = Vec::new();
10733 super::expand_initial_peer("127.0.0.1", 0, 30, &mut wide);
10734 assert_eq!(wide.len(), 30);
10735 assert_eq!(wide[29].port, 7410 + 2 * 29);
10736 // ip:port -> exactly one exact locator.
10737 let mut one = Vec::new();
10738 super::expand_initial_peer("10.0.0.5:7410", 0, m, &mut one);
10739 assert_eq!(one.len(), 1);
10740 assert_eq!(one[0].port, 7410);
10741 assert_eq!(one[0].address[12..], [10, 0, 0, 5]);
10742 // Garbage is ignored.
10743 let mut none = Vec::new();
10744 super::expand_initial_peer("not-an-ip", 0, m, &mut none);
10745 assert!(none.is_empty());
10746 }
10747
10748 #[test]
10749 #[ignore = "heavy multi-runtime scaling test (12 runtimes); explicit: cargo test -- --ignored"]
10750 #[allow(clippy::print_stdout)]
10751 fn multicast_free_discovery_scales_to_many_participants() {
10752 // C1 scaling: N participants, each with its own multicast group
10753 // (→ separate inproc buckets) AND multicast send off → pure
10754 // Unicast discovery via an explicit well-known-port peer list. Evidence,
10755 // that multicast-free all-to-all discovery works beyond 2 participants
10756 // (the "N²-multicast-storm" pain cluster, but unicast).
10757 // N via env (ZERODDS_SCALE_N, default 12) for >50 perf demos.
10758 let n: u32 = std::env::var("ZERODDS_SCALE_N")
10759 .ok()
10760 .and_then(|s| s.parse().ok())
10761 .unwrap_or(12)
10762 .clamp(2, 120);
10763 let domain = 21;
10764 let peers: Vec<Locator> = (0..n)
10765 .map(|pid| Locator::udp_v4([127, 0, 0, 1], super::spdp_unicast_port(domain, pid)))
10766 .collect();
10767 let mut rts = Vec::new();
10768 for i in 0..n {
10769 let cfg = RuntimeConfig {
10770 tick_period: Duration::from_millis(10),
10771 spdp_period: Duration::from_millis(40),
10772 // Own group per runtime → no inproc, no multicast.
10773 spdp_multicast_group: Ipv4Addr::new(239, 255, 21, (i + 1) as u8),
10774 spdp_multicast_send: false,
10775 initial_peers: peers.clone(),
10776 ..RuntimeConfig::default()
10777 };
10778 // Unique prefix even for n>47 (two-byte index).
10779 let mut pb = [0xD0u8; 12];
10780 pb[0] = (i & 0xff) as u8;
10781 pb[1] = (i >> 8) as u8;
10782 let prefix = GuidPrefix::from_bytes(pb);
10783 rts.push(DcpsRuntime::start(domain as i32, prefix, cfg).expect("start"));
10784 }
10785 // Wait until each participant has discovered all n-1 others.
10786 // Grosszuegiges Fenster: viele Runtimes konkurrieren um CPU; break-early.
10787 let started = std::time::Instant::now();
10788 let mut all_full = false;
10789 for _ in 0..1200 {
10790 std::thread::sleep(Duration::from_millis(25));
10791 if rts
10792 .iter()
10793 .all(|rt| rt.discovered_participants().len() >= (n as usize - 1))
10794 {
10795 all_full = true;
10796 break;
10797 }
10798 }
10799 let elapsed = started.elapsed();
10800 let min_seen = rts
10801 .iter()
10802 .map(|rt| rt.discovered_participants().len())
10803 .min()
10804 .unwrap_or(0);
10805 for rt in &rts {
10806 rt.shutdown();
10807 }
10808 println!(
10809 "C1-Scaling: {n} Participants multicast-frei all-to-all in {:.2}s (min={min_seen}/{})",
10810 elapsed.as_secs_f64(),
10811 n - 1
10812 );
10813 assert!(
10814 all_full,
10815 "multicast-free all-to-all discovery does not scale: min seen = {min_seen}/{}",
10816 n - 1
10817 );
10818 }
10819
10820 #[test]
10821 fn default_reassembly_cap_is_ros_realistic() {
10822 // C3 regression: the DCPS reassembly cap must be ROS-PointCloud2/
10823 // Image-capable (several MB), not the conservative
10824 // rtps 1-MiB default that silently discards large samples.
10825 let cfg = RuntimeConfig::default();
10826 assert!(
10827 cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024,
10828 "reassembly cap too small for ROS PointCloud2/Image: {}",
10829 cfg.max_reassembly_sample_bytes
10830 );
10831 }
10832
10833 #[test]
10834 fn ros_defaults_offers_xcdr1_for_ros_writers() {
10835 // C4: the ROS profile offers [XCDR1, XCDR2] (matches ROS/Cyclone
10836 // XCDR1 writer) + keeps the ROS-realistic reassembly cap.
10837 use zerodds_rtps::publication_data::data_representation as dr;
10838 let cfg = RuntimeConfig::ros_defaults();
10839 assert_eq!(
10840 cfg.data_representation_offer,
10841 alloc::vec![dr::XCDR, dr::XCDR2]
10842 );
10843 assert!(cfg.max_reassembly_sample_bytes >= 8 * 1024 * 1024);
10844 }
10845
10846 #[test]
10847 fn multicast_free_discovery_via_initial_peers() {
10848 // C1: two runtimes with DIFFERENT multicast groups lie
10849 // in different inproc buckets AND cannot see each other via
10850 // multicast — so they discover each other EXCLUSIVELY via
10851 // the unicast initial peers (well-known SPDP ports on 127.0.0.1).
10852 let domain = 7;
10853 let mut peers = Vec::new();
10854 super::expand_initial_peer(
10855 "127.0.0.1",
10856 domain as u32,
10857 super::INITIAL_PEER_MAX_PARTICIPANTS,
10858 &mut peers,
10859 );
10860 let mk = |group: [u8; 4]| RuntimeConfig {
10861 tick_period: Duration::from_millis(10),
10862 spdp_period: Duration::from_millis(40),
10863 spdp_multicast_group: Ipv4Addr::from(group),
10864 // Multicast send fully off → rigorous unicast-only proof.
10865 spdp_multicast_send: false,
10866 initial_peers: peers.clone(),
10867 ..RuntimeConfig::default()
10868 };
10869 let a = DcpsRuntime::start(
10870 domain,
10871 GuidPrefix::from_bytes([0xA1; 12]),
10872 mk([239, 255, 7, 1]),
10873 )
10874 .expect("a");
10875 let b = DcpsRuntime::start(
10876 domain,
10877 GuidPrefix::from_bytes([0xB2; 12]),
10878 mk([239, 255, 7, 2]),
10879 )
10880 .expect("b");
10881 let mut discovered = false;
10882 for _ in 0..160 {
10883 std::thread::sleep(Duration::from_millis(25));
10884 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
10885 discovered = true;
10886 break;
10887 }
10888 }
10889 a.shutdown();
10890 b.shutdown();
10891 assert!(
10892 discovered,
10893 "multicast-freie Discovery via Unicast-Initial-Peers fehlgeschlagen"
10894 );
10895 }
10896
10897 #[test]
10898 fn multi_robot_profile_is_multicast_free_and_wan_tolerant() {
10899 // C6: the named profile must be unicast-only with ROS reprs and a
10900 // WAN-tolerant lease, independent of any env.
10901 let cfg = RuntimeConfig::multi_robot();
10902 assert!(
10903 !cfg.spdp_multicast_send,
10904 "multi_robot() must disable multicast send"
10905 );
10906 assert_eq!(
10907 cfg.data_representation_offer,
10908 alloc::vec![
10909 zerodds_rtps::publication_data::data_representation::XCDR,
10910 zerodds_rtps::publication_data::data_representation::XCDR2
10911 ],
10912 "multi_robot() must offer the ROS XCDR1+XCDR2 reprs"
10913 );
10914 assert_eq!(
10915 cfg.participant_lease_duration,
10916 Duration::from_secs(300),
10917 "multi_robot() must use the WAN-tolerant 300s lease"
10918 );
10919 }
10920
10921 #[test]
10922 fn multi_robot_profile_discovers_via_unicast() {
10923 // C6 e2e: two runtimes started from the `multi_robot()` profile (whose
10924 // `spdp_multicast_send = false` is the field under test) sit in
10925 // different multicast buckets and can ONLY find each other through the
10926 // unicast initial peers — proving the profile drives multicast-free
10927 // discovery end-to-end. Only test-timing + the peer list are
10928 // overridden; `spdp_multicast_send` comes from the profile.
10929 let domain = 9;
10930 let mut peers = Vec::new();
10931 super::expand_initial_peer(
10932 "127.0.0.1",
10933 domain as u32,
10934 super::INITIAL_PEER_MAX_PARTICIPANTS,
10935 &mut peers,
10936 );
10937 let mk = |group: [u8; 4]| RuntimeConfig {
10938 tick_period: Duration::from_millis(10),
10939 spdp_period: Duration::from_millis(40),
10940 spdp_multicast_group: Ipv4Addr::from(group),
10941 initial_peers: peers.clone(),
10942 ..RuntimeConfig::multi_robot()
10943 };
10944 let a = DcpsRuntime::start(
10945 domain,
10946 GuidPrefix::from_bytes([0xC6; 12]),
10947 mk([239, 255, 9, 1]),
10948 )
10949 .expect("a");
10950 let b = DcpsRuntime::start(
10951 domain,
10952 GuidPrefix::from_bytes([0xD7; 12]),
10953 mk([239, 255, 9, 2]),
10954 )
10955 .expect("b");
10956 let mut discovered = false;
10957 for _ in 0..160 {
10958 std::thread::sleep(Duration::from_millis(25));
10959 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
10960 discovered = true;
10961 break;
10962 }
10963 }
10964 a.shutdown();
10965 b.shutdown();
10966 assert!(
10967 discovered,
10968 "multi_robot() profile failed to discover via unicast initial peers"
10969 );
10970 }
10971
10972 #[test]
10973 fn intra_runtime_writer_to_reader_loopback_delivers_sample() {
10974 // Bridge daemon use case: writer and reader in the SAME
10975 // DcpsRuntime, same topic+type. Before the same-runtime loopback
10976 // hook, a write() produced NO sample at the local reader,
10977 // because `inproc_announce_*` explicitly skips self and UDP multicast
10978 // loopback is not guaranteed.
10979 let rt = DcpsRuntime::start(
10980 17,
10981 GuidPrefix::from_bytes([0x42; 12]),
10982 RuntimeConfig::default(),
10983 )
10984 .expect("start runtime");
10985 let writer_eid = rt
10986 .register_user_writer(UserWriterConfig {
10987 topic_name: "IntraTopic".into(),
10988 type_name: "IntraType".into(),
10989 reliable: true,
10990 durability: zerodds_qos::DurabilityKind::Volatile,
10991 deadline: zerodds_qos::DeadlineQosPolicy::default(),
10992 lifespan: zerodds_qos::LifespanQosPolicy::default(),
10993 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
10994 ownership: zerodds_qos::OwnershipKind::Shared,
10995 ownership_strength: 0,
10996 partition: alloc::vec![],
10997 user_data: alloc::vec![],
10998 topic_data: alloc::vec![],
10999 group_data: alloc::vec![],
11000 type_identifier: zerodds_types::TypeIdentifier::None,
11001 data_representation_offer: None,
11002 })
11003 .expect("register writer");
11004 let (_reader_eid, rx) = rt
11005 .register_user_reader(UserReaderConfig {
11006 topic_name: "IntraTopic".into(),
11007 type_name: "IntraType".into(),
11008 reliable: true,
11009 durability: zerodds_qos::DurabilityKind::Volatile,
11010 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11011 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11012 ownership: zerodds_qos::OwnershipKind::Shared,
11013 partition: alloc::vec![],
11014 user_data: alloc::vec![],
11015 topic_data: alloc::vec![],
11016 group_data: alloc::vec![],
11017 type_identifier: zerodds_types::TypeIdentifier::None,
11018 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11019 data_representation_offer: None,
11020 })
11021 .expect("register reader");
11022
11023 rt.write_user_sample(writer_eid, b"hello-intra-runtime".to_vec())
11024 .expect("write");
11025
11026 // Same-runtime loopback is synchronous in the write_user_sample_borrowed
11027 // path — `recv_timeout` needs only microseconds, not the
11028 // wire roundtrip.
11029 let sample = rx
11030 .recv_timeout(core::time::Duration::from_millis(100))
11031 .expect("intra-runtime reader should receive sample");
11032 match sample {
11033 UserSample::Alive { payload, .. } => {
11034 assert_eq!(payload.as_ref(), b"hello-intra-runtime");
11035 }
11036 other => panic!("expected Alive, got {other:?}"),
11037 }
11038 rt.shutdown();
11039 }
11040
11041 #[test]
11042 fn intra_runtime_loopback_not_matched_on_different_topic() {
11043 // Negative test: writer on TopicA, reader on TopicB — no
11044 // intra-runtime match, no sample. Prevents the
11045 // routing table from topic-blindly merging everything.
11046 let rt = DcpsRuntime::start(
11047 18,
11048 GuidPrefix::from_bytes([0x43; 12]),
11049 RuntimeConfig::default(),
11050 )
11051 .expect("start runtime");
11052 let writer_eid = rt
11053 .register_user_writer(UserWriterConfig {
11054 topic_name: "TopicA".into(),
11055 type_name: "TypeA".into(),
11056 reliable: true,
11057 durability: zerodds_qos::DurabilityKind::Volatile,
11058 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11059 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11060 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11061 ownership: zerodds_qos::OwnershipKind::Shared,
11062 ownership_strength: 0,
11063 partition: alloc::vec![],
11064 user_data: alloc::vec![],
11065 topic_data: alloc::vec![],
11066 group_data: alloc::vec![],
11067 type_identifier: zerodds_types::TypeIdentifier::None,
11068 data_representation_offer: None,
11069 })
11070 .expect("register writer");
11071 let (_reader_eid, rx) = rt
11072 .register_user_reader(UserReaderConfig {
11073 topic_name: "TopicB".into(),
11074 type_name: "TypeB".into(),
11075 reliable: true,
11076 durability: zerodds_qos::DurabilityKind::Volatile,
11077 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11078 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11079 ownership: zerodds_qos::OwnershipKind::Shared,
11080 partition: alloc::vec![],
11081 user_data: alloc::vec![],
11082 topic_data: alloc::vec![],
11083 group_data: alloc::vec![],
11084 type_identifier: zerodds_types::TypeIdentifier::None,
11085 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11086 data_representation_offer: None,
11087 })
11088 .expect("register reader");
11089
11090 rt.write_user_sample(writer_eid, b"should-not-arrive".to_vec())
11091 .expect("write");
11092
11093 match rx.recv_timeout(core::time::Duration::from_millis(50)) {
11094 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { /* expected */ }
11095 other => panic!("reader on different topic must not receive: got {other:?}"),
11096 }
11097 rt.shutdown();
11098 }
11099
11100 #[test]
11101 fn runtime_starts_and_shuts_down_cleanly() {
11102 let rt = DcpsRuntime::start(
11103 42,
11104 GuidPrefix::from_bytes([7; 12]),
11105 RuntimeConfig::default(),
11106 )
11107 .expect("start runtime");
11108 assert_eq!(rt.domain_id, 42);
11109 // Wave 4b.2 (Spec `zerodds-zero-copy-1.0` §6): the SameHostTracker
11110 // must be initially empty and a same-host match (manually
11111 // simulated, without SEDP setup) must produce a `Pending`
11112 // entry. The real SEDP hook trigger is the job of the E2E
11113 // test in wave 4c — here only a smoke test of the wiring point.
11114 assert!(rt.same_host.is_empty(), "fresh runtime: no same-host pairs");
11115 let local_writer = zerodds_rtps::wire_types::Guid::new(
11116 rt.guid_prefix,
11117 zerodds_rtps::wire_types::EntityId::user_writer_with_key([1, 2, 3]),
11118 );
11119 let same_host_reader = zerodds_rtps::wire_types::Guid::new(
11120 rt.guid_prefix,
11121 zerodds_rtps::wire_types::EntityId::user_reader_with_key([4, 5, 6]),
11122 );
11123 rt.same_host
11124 .register_pending(local_writer, same_host_reader);
11125 assert_eq!(rt.same_host.len(), 1);
11126 assert!(matches!(
11127 rt.same_host.lookup(local_writer, same_host_reader),
11128 Some(crate::same_host::SameHostState::Pending)
11129 ));
11130 // Shutdown is idempotent.
11131 rt.shutdown();
11132 rt.shutdown();
11133 }
11134
11135 #[test]
11136 fn spdp_announces_standard_bits_by_default() {
11137 // Default config (without security): standard bits + WLP bits 10/11
11138 // + TypeLookup bits 12/13 must be announced along;
11139 // secure bits 16..27 + SEDP-topics bits 28/29 must NOT
11140 // be set. Topics bits are optional per RTPS 2.5 §8.5.4.4
11141 // — ZeroDDS does not implement the native topic endpoints
11142 // (synthetic DCPSTopic derivation from pub/sub covers the
11143 // end-user need), so we do not announce the capability
11144 // either.
11145 let rt = DcpsRuntime::start(
11146 5,
11147 GuidPrefix::from_bytes([0xC; 12]),
11148 RuntimeConfig::default(),
11149 )
11150 .expect("start");
11151 let mask = rt.announced_builtin_endpoint_set();
11152 // Standard bits + WLP + TypeLookup.
11153 assert_ne!(mask & endpoint_flag::PARTICIPANT_ANNOUNCER, 0);
11154 assert_ne!(mask & endpoint_flag::PARTICIPANT_DETECTOR, 0);
11155 assert_ne!(mask & endpoint_flag::PUBLICATIONS_ANNOUNCER, 0);
11156 assert_ne!(mask & endpoint_flag::SUBSCRIPTIONS_DETECTOR, 0);
11157 assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_WRITER, 0);
11158 assert_ne!(mask & endpoint_flag::PARTICIPANT_MESSAGE_DATA_READER, 0);
11159 assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REQUEST, 0);
11160 assert_ne!(mask & endpoint_flag::TYPE_LOOKUP_REPLY, 0);
11161 // Do NOT set the SEDP-topics bits — covered synthetically.
11162 assert_eq!(mask & endpoint_flag::TOPICS_ANNOUNCER, 0);
11163 assert_eq!(mask & endpoint_flag::TOPICS_DETECTOR, 0);
11164 // No secure bits without explicit announce_secure_endpoints.
11165 assert_eq!(mask & endpoint_flag::ALL_SECURE, 0);
11166 }
11167
11168 #[test]
11169 fn spdp_announces_secure_bits_when_configured() {
11170 // With announce_secure_endpoints=true all 12 secure
11171 // bits (16..27) must be set.
11172 let config = RuntimeConfig {
11173 announce_secure_endpoints: true,
11174 ..Default::default()
11175 };
11176 let rt = DcpsRuntime::start(6, GuidPrefix::from_bytes([0xD; 12]), config).expect("start");
11177 let mask = rt.announced_builtin_endpoint_set();
11178 for bit in 16u32..=27 {
11179 assert!(
11180 mask & (1u32 << bit) != 0,
11181 "secure bit {bit} missing in the SPDP announce"
11182 );
11183 }
11184 // Standard bits must still be set.
11185 assert_eq!(
11186 mask & endpoint_flag::ALL_STANDARD,
11187 endpoint_flag::ALL_STANDARD
11188 );
11189 }
11190
11191 #[test]
11192 fn spdp_lease_duration_is_configurable() {
11193 // Default 100 s (spec). The override of 17 s must arrive in the beacon.
11194 let config = RuntimeConfig {
11195 participant_lease_duration: Duration::from_secs(17),
11196 ..Default::default()
11197 };
11198 let rt = DcpsRuntime::start(7, GuidPrefix::from_bytes([0xE; 12]), config).expect("start");
11199 let secs = rt
11200 .spdp_beacon
11201 .lock()
11202 .map(|b| b.data.lease_duration.seconds)
11203 .unwrap_or(0);
11204 assert_eq!(secs, 17);
11205 }
11206
11207 #[test]
11208 fn user_locator_is_udp_v4_127_0_0_x() {
11209 let rt = DcpsRuntime::start(
11210 0,
11211 GuidPrefix::from_bytes([0xA; 12]),
11212 RuntimeConfig::default(),
11213 )
11214 .expect("start");
11215 let loc = rt.user_locator();
11216 assert_eq!(loc.kind, zerodds_rtps::wire_types::LocatorKind::UdpV4);
11217 // Port > 0 (ephemeral).
11218 assert!(loc.port > 0);
11219 }
11220
11221 #[test]
11222 fn two_runtimes_on_same_domain_can_coexist() {
11223 // The SPDP multicast port is SO_REUSE in our bind.
11224 let a = DcpsRuntime::start(
11225 3,
11226 GuidPrefix::from_bytes([0xA; 12]),
11227 RuntimeConfig::default(),
11228 )
11229 .expect("a");
11230 let b = DcpsRuntime::start(
11231 3,
11232 GuidPrefix::from_bytes([0xB; 12]),
11233 RuntimeConfig::default(),
11234 )
11235 .expect("b");
11236 assert_eq!(a.domain_id, b.domain_id);
11237 }
11238
11239 #[test]
11240 fn peer_capabilities_unknown_peer_returns_none() {
11241 let rt = DcpsRuntime::start(
11242 10,
11243 GuidPrefix::from_bytes([0x60; 12]),
11244 RuntimeConfig::default(),
11245 )
11246 .expect("start");
11247 // A fresh runtime has discovered no peer.
11248 let caps = rt.peer_capabilities(&GuidPrefix::from_bytes([0xEE; 12]));
11249 assert!(caps.is_none());
11250 }
11251
11252 #[test]
11253 fn assert_liveliness_enqueues_wlp_pulse_without_panic() {
11254 // Smoke test: assert_liveliness() must not poison the lock
11255 // and must return synchronously.
11256 let rt = DcpsRuntime::start(
11257 8,
11258 GuidPrefix::from_bytes([0xF; 12]),
11259 RuntimeConfig::default(),
11260 )
11261 .expect("start");
11262 rt.assert_liveliness();
11263 rt.assert_writer_liveliness(alloc::vec![0xDE, 0xAD]);
11264 // The lock must stay usable.
11265 let count = rt.wlp.lock().map(|w| w.peer_count()).unwrap_or(usize::MAX);
11266 assert_eq!(count, 0, "no peer announced itself → 0");
11267 }
11268
11269 #[test]
11270 fn wlp_period_default_is_lease_over_three() {
11271 // With the default lease of 100 s → wlp_period = 33.33 s.
11272 let rt = DcpsRuntime::start(
11273 9,
11274 GuidPrefix::from_bytes([0x10; 12]),
11275 RuntimeConfig::default(),
11276 )
11277 .expect("start");
11278 // We cannot read the value directly; but we
11279 // know: tick_period > 30 s means the default lease was
11280 // used. Enqueue a pulse and tick — it must fire,
11281 // the next AUTOMATIC comes only in 33 s.
11282 let mut wlp = rt.wlp.lock().unwrap();
11283 wlp.assert_participant();
11284 let now0 = Duration::from_secs(0);
11285 let dg = wlp.tick(now0).unwrap();
11286 assert!(dg.is_some(), "pulse is emitted immediately");
11287 }
11288
11289 // Multicast loopback is unreliable on macOS (no auto-
11290 // interface-join with bind_multicast_v4(0.0.0.0)). On Linux
11291 // it works out of the box; there the test will run in CI.
11292 #[cfg(target_os = "linux")]
11293 #[test]
11294 fn two_runtimes_exchange_wlp_heartbeat_via_multicast() {
11295 // .D-e: A sends periodic WLP heartbeats. B must
11296 // know its own WLP endpoint with A's prefix as a peer
11297 // within ~3 tick periods.
11298 let cfg = RuntimeConfig {
11299 tick_period: Duration::from_millis(20),
11300 spdp_period: Duration::from_millis(100),
11301 // Aggressive WLP period for fast tests.
11302 wlp_period: Duration::from_millis(80),
11303 participant_lease_duration: Duration::from_millis(240),
11304 ..RuntimeConfig::default()
11305 };
11306 let _a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x40; 12]), cfg.clone()).expect("a");
11307 let _b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0x41; 12]), cfg).expect("b");
11308
11309 let a_prefix = GuidPrefix::from_bytes([0x40; 12]);
11310 for _ in 0..60 {
11311 thread::sleep(Duration::from_millis(50));
11312 if _b.peer_liveliness_last_seen(&a_prefix).is_some() {
11313 return;
11314 }
11315 }
11316 panic!("B did not see A's WLP heartbeat within 3 s");
11317 }
11318
11319 #[cfg(target_os = "linux")]
11320 #[test]
11321 fn two_runtimes_assert_liveliness_reaches_peer() {
11322 // The Manual-By-Participant pulse must arrive at the peer, the
11323 // last-seen timestamp must reset compared to purely Automatic
11324 // beats. Since the pulse goes out synchronously on the next
11325 // tick, a short wait suffices.
11326 let cfg = RuntimeConfig {
11327 tick_period: Duration::from_millis(20),
11328 spdp_period: Duration::from_millis(100),
11329 // WLP period large enough that no AUTOMATIC beat comes
11330 // in between within the test. The manual pulse queue
11331 // is processed before the AUTOMATIC slot.
11332 wlp_period: Duration::from_secs(3600),
11333 ..RuntimeConfig::default()
11334 };
11335 let a = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x50; 12]), cfg.clone()).expect("a");
11336 let b = DcpsRuntime::start(4, GuidPrefix::from_bytes([0x51; 12]), cfg).expect("b");
11337
11338 a.assert_liveliness();
11339 let a_prefix = GuidPrefix::from_bytes([0x50; 12]);
11340 for _ in 0..60 {
11341 thread::sleep(Duration::from_millis(50));
11342 if b.peer_liveliness_last_seen(&a_prefix).is_some() {
11343 return;
11344 }
11345 }
11346 // In case of multicast-loopback problems, at least check A's
11347 // own pulse counter.
11348 panic!("B did not see A's manual liveliness assert within 3 s");
11349 }
11350
11351 #[cfg(target_os = "linux")]
11352 #[test]
11353 fn two_runtimes_exchange_sedp_publication_announce() {
11354 // E2E smoke: A announces a publication, B sees it
11355 // via SEDP. Assumes SPDP works (so that
11356 // the SEDP peer proxies get wired).
11357 use zerodds_qos::{DurabilityKind, ReliabilityKind};
11358 use zerodds_rtps::publication_data::PublicationBuiltinTopicData;
11359
11360 let cfg = RuntimeConfig {
11361 tick_period: Duration::from_millis(20),
11362 spdp_period: Duration::from_millis(100),
11363 ..RuntimeConfig::default()
11364 };
11365 // Own domain, so the test does not collide with the SPDP-only test
11366 // on domain 0 over the multicast port.
11367 let a = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xCC; 12]), cfg.clone()).expect("a");
11368 let b = DcpsRuntime::start(1, GuidPrefix::from_bytes([0xDD; 12]), cfg).expect("b");
11369
11370 // Wait until both see each other via SPDP.
11371 for _ in 0..40 {
11372 thread::sleep(Duration::from_millis(50));
11373 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
11374 break;
11375 }
11376 }
11377 assert!(
11378 !a.discovered_participants().is_empty(),
11379 "no SPDP discovery a"
11380 );
11381
11382 // A announces a publication for topic "Chatter" with type "RawBytes".
11383 let pub_data = PublicationBuiltinTopicData {
11384 key: Guid::new(
11385 a.guid_prefix,
11386 EntityId::user_writer_with_key([0x01, 0x02, 0x03]),
11387 ),
11388 participant_key: Guid::new(a.guid_prefix, EntityId::PARTICIPANT),
11389 topic_name: "Chatter".into(),
11390 type_name: "zerodds::RawBytes".into(),
11391 durability: DurabilityKind::Volatile,
11392 reliability: zerodds_qos::ReliabilityQosPolicy {
11393 kind: ReliabilityKind::Reliable,
11394 max_blocking_time: QosDuration::from_millis(100_i32),
11395 },
11396 ownership: zerodds_qos::OwnershipKind::Shared,
11397 ownership_strength: 0,
11398 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11399 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11400 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11401 partition: Vec::new(),
11402 user_data: Vec::new(),
11403 topic_data: Vec::new(),
11404 group_data: Vec::new(),
11405 type_information: None,
11406 data_representation: Vec::new(),
11407 security_info: None,
11408 service_instance_name: None,
11409 related_entity_guid: None,
11410 topic_aliases: None,
11411 type_identifier: zerodds_types::TypeIdentifier::None,
11412 unicast_locators: Vec::new(),
11413 multicast_locators: Vec::new(),
11414 };
11415 a.announce_publication(&pub_data).expect("announce");
11416
11417 // B should have the publication in the cache within ~3 s.
11418 // CI on shared runners has more jitter, 1 s was too tight.
11419 for _ in 0..60 {
11420 thread::sleep(Duration::from_millis(50));
11421 if b.discovered_publications_count() > 0 {
11422 return;
11423 }
11424 }
11425 panic!(
11426 "B did not receive SEDP publication within 3 s (pub_count={})",
11427 b.discovered_publications_count()
11428 );
11429 }
11430
11431 #[cfg(target_os = "linux")]
11432 #[test]
11433 fn two_runtimes_e2e_user_data_match_and_transfer() {
11434 // E2E smoke: kompletter Pfad
11435 // Runtime-A register_user_writer(topic, type)
11436 // Runtime-B register_user_reader(topic, type)
11437 // SEDP match, writer add_reader_proxy, reader add_writer_proxy
11438 // A.write_user_sample(payload) → UDP → B's mpsc::Receiver
11439 //
11440 // Eigene Domain (2) um Kollisionen zu vermeiden.
11441 let cfg = RuntimeConfig {
11442 tick_period: Duration::from_millis(20),
11443 spdp_period: Duration::from_millis(100),
11444 ..RuntimeConfig::default()
11445 };
11446 let a = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xEE; 12]), cfg.clone()).expect("a");
11447 let b = DcpsRuntime::start(2, GuidPrefix::from_bytes([0xFF; 12]), cfg).expect("b");
11448
11449 // SPDP mutual — 3 s Budget.
11450 let mut spdp_ok = false;
11451 for _ in 0..60 {
11452 thread::sleep(Duration::from_millis(50));
11453 if !a.discovered_participants().is_empty() && !b.discovered_participants().is_empty() {
11454 spdp_ok = true;
11455 break;
11456 }
11457 }
11458 assert!(spdp_ok, "SPDP mutual discovery did not complete in 3 s");
11459
11460 // Register endpoints. A publish, B subscribe.
11461 let wid = a
11462 .register_user_writer(UserWriterConfig {
11463 topic_name: "Chatter".into(),
11464 type_name: "zerodds::RawBytes".into(),
11465 reliable: true,
11466 durability: zerodds_qos::DurabilityKind::Volatile,
11467 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11468 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11469 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11470 ownership: zerodds_qos::OwnershipKind::Shared,
11471 ownership_strength: 0,
11472 partition: Vec::new(),
11473 user_data: Vec::new(),
11474 topic_data: Vec::new(),
11475 group_data: Vec::new(),
11476 type_identifier: zerodds_types::TypeIdentifier::None,
11477 data_representation_offer: None,
11478 })
11479 .expect("wid");
11480 let (_rid, rx) = b
11481 .register_user_reader(UserReaderConfig {
11482 topic_name: "Chatter".into(),
11483 type_name: "zerodds::RawBytes".into(),
11484 reliable: true,
11485 durability: zerodds_qos::DurabilityKind::Volatile,
11486 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11487 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11488 ownership: zerodds_qos::OwnershipKind::Shared,
11489 partition: Vec::new(),
11490 user_data: Vec::new(),
11491 topic_data: Vec::new(),
11492 group_data: Vec::new(),
11493 type_identifier: zerodds_types::TypeIdentifier::None,
11494 type_consistency: zerodds_types::qos::TypeConsistencyEnforcement::default(),
11495 data_representation_offer: None,
11496 })
11497 .expect("rid");
11498
11499 // SEDP match + User-Data-Flow. `add_reader_proxy` triggert
11500 // a heartbeat immediately (RTPS §8.4.15.4), so ~tick_period
11501 // (20 ms) + response-delay (200 ms) + resend ≈ 300 ms in
11502 // idle state. A 4 s budget suffices even with CI jitter.
11503 let mut attempts = 0;
11504 loop {
11505 thread::sleep(Duration::from_millis(50));
11506 let _ = a.write_user_sample(wid, alloc::vec![0xAA, 0xBB, 0xCC]);
11507 if let Ok(sample) = rx.recv_timeout(Duration::from_millis(50)) {
11508 match sample {
11509 UserSample::Alive { payload, .. } => {
11510 assert_eq!(payload.as_slice(), &[0xAA, 0xBB, 0xCC][..]);
11511 return;
11512 }
11513 other => panic!("expected Alive sample, got {other:?}"),
11514 }
11515 }
11516 attempts += 1;
11517 if attempts > 80 {
11518 panic!("no sample delivered within 4 s");
11519 }
11520 }
11521 }
11522
11523 #[cfg(target_os = "linux")]
11524 #[test]
11525 fn two_runtimes_discover_each_other_via_spdp() {
11526 // We use a tight SPDP period so the test does not wait 5 s.
11527 let cfg = RuntimeConfig {
11528 tick_period: Duration::from_millis(20),
11529 spdp_period: Duration::from_millis(100),
11530 ..RuntimeConfig::default()
11531 };
11532 // Eigene Domain 3 (SEDP=1, E2E=2) um Cross-Test-Kollision zu vermeiden.
11533 let a = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xAA; 12]), cfg.clone()).expect("a");
11534 let b = DcpsRuntime::start(3, GuidPrefix::from_bytes([0xBB; 12]), cfg).expect("b");
11535
11536 // Give the loop time for 2-3 beacon rounds. Multicast on
11537 // loopback is somewhat timing-sensitive when parallel tests
11538 // share the multicast group — hence 60 iterations of 50 ms
11539 // = 3 s budget instead of 1 s.
11540 for _ in 0..60 {
11541 thread::sleep(Duration::from_millis(50));
11542 let a_sees_b = a
11543 .discovered_participants()
11544 .iter()
11545 .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xBB; 12]));
11546 let b_sees_a = b
11547 .discovered_participants()
11548 .iter()
11549 .any(|p| p.sender_prefix == GuidPrefix::from_bytes([0xAA; 12]));
11550 if a_sees_b && b_sees_a {
11551 return;
11552 }
11553 }
11554 panic!(
11555 "mutual SPDP discovery failed within 3 s (a={} b={})",
11556 a.discovered_participants().len(),
11557 b.discovered_participants().len()
11558 );
11559 }
11560
11561 // =======================================================================
11562 // Security: Writer-Side Per-Reader-Serializer
11563 // =======================================================================
11564
11565 #[cfg(feature = "security")]
11566 #[test]
11567 fn per_target_serializer_produces_different_wire_per_reader() {
11568 use zerodds_security_crypto::AesGcmCryptoPlugin;
11569 use zerodds_security_permissions::parse_governance_xml;
11570 use zerodds_security_runtime::{
11571 PeerCapabilities, ProtectionLevel as SecProtectionLevel, SharedSecurityGate,
11572 };
11573
11574 // The governance enforces ENCRYPT on domain 0 — the default
11575 // path (transform_outbound) wraps too. A per-reader override
11576 // can still deliver plaintext if the reader is legacy.
11577 const GOV: &str = r#"
11578<domain_access_rules>
11579 <domain_rule>
11580 <domains><id>0</id></domains>
11581 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
11582 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11583 </domain_rule>
11584</domain_access_rules>
11585"#;
11586 let gate = SharedSecurityGate::new(
11587 0,
11588 parse_governance_xml(GOV).unwrap(),
11589 Box::new(AesGcmCryptoPlugin::new()),
11590 );
11591
11592 let cfg = RuntimeConfig {
11593 security: Some(std::sync::Arc::new(gate)),
11594 ..RuntimeConfig::default()
11595 };
11596 let rt =
11597 DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE4; 12]), cfg).expect("start runtime");
11598
11599 let wid = rt
11600 .register_user_writer(UserWriterConfig {
11601 topic_name: "HeteroTopic".into(),
11602 type_name: "zerodds::RawBytes".into(),
11603 reliable: true,
11604 durability: zerodds_qos::DurabilityKind::Volatile,
11605 deadline: zerodds_qos::DeadlineQosPolicy::default(),
11606 lifespan: zerodds_qos::LifespanQosPolicy::default(),
11607 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
11608 ownership: zerodds_qos::OwnershipKind::Shared,
11609 ownership_strength: 0,
11610 partition: Vec::new(),
11611 user_data: Vec::new(),
11612 topic_data: Vec::new(),
11613 group_data: Vec::new(),
11614 type_identifier: zerodds_types::TypeIdentifier::None,
11615 data_representation_offer: None,
11616 })
11617 .expect("register writer");
11618
11619 // Drei fiktive Reader-Targets — eines pro Protection-Klasse.
11620 let legacy_loc = Locator::udp_v4([127, 0, 0, 11], 40001);
11621 let fast_loc = Locator::udp_v4([127, 0, 0, 12], 40002);
11622 let secure_loc = Locator::udp_v4([127, 0, 0, 13], 40003);
11623 let legacy_peer: [u8; 12] = [0x11; 12];
11624 let fast_peer: [u8; 12] = [0x22; 12];
11625 let secure_peer: [u8; 12] = [0x33; 12];
11626
11627 // Simulates the SEDP match: populate the writer-slot maps.
11628 {
11629 let arc = rt.writer_slot(wid).unwrap();
11630 let mut slot = arc.lock().unwrap();
11631 slot.reader_protection
11632 .insert(legacy_peer, SecProtectionLevel::None);
11633 slot.reader_protection
11634 .insert(fast_peer, SecProtectionLevel::Sign);
11635 slot.reader_protection
11636 .insert(secure_peer, SecProtectionLevel::Encrypt);
11637 slot.locator_to_peer.insert(legacy_loc, legacy_peer);
11638 slot.locator_to_peer.insert(fast_loc, fast_peer);
11639 slot.locator_to_peer.insert(secure_loc, secure_peer);
11640 }
11641
11642 // Fiktive Writer-Datagram-Bytes (RTPS-Header + User-Payload).
11643 let mut msg = Vec::new();
11644 msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
11645 msg.extend_from_slice(&[0xE4; 12]); // GuidPrefix
11646 msg.extend_from_slice(b"HELLO-HETERO");
11647
11648 let wire_legacy =
11649 secure_outbound_for_target(&rt, wid, &msg, &legacy_loc).expect("legacy path");
11650 let wire_fast = secure_outbound_for_target(&rt, wid, &msg, &fast_loc).expect("fast path");
11651 let wire_secure =
11652 secure_outbound_for_target(&rt, wid, &msg, &secure_loc).expect("secure path");
11653
11654 // Spec §8.4.2.4: under rtps_protection_kind=ENCRYPT EVERY message MUST
11655 // be SRTPS-wrapped — even a legacy reader (data-level None) may
11656 // get NO plaintext, otherwise user DATA leaks on a protected
11657 // domain. The per-reader data level only controls the inner payload/
11658 // submessage layer, not the outer rtps_protection.
11659 assert_ne!(
11660 wire_legacy, msg,
11661 "legacy under rtps_protection=ENCRYPT MUST be SRTPS-wrapped (no plaintext leak)"
11662 );
11663 assert_ne!(wire_fast, msg, "fast reader must be protected");
11664 assert_ne!(wire_secure, msg, "secure reader must be protected");
11665
11666 // Heterogeneity proof: the three wires are pairwise
11667 // different (each with its own nonce/session counter in SRTPS).
11668 assert_ne!(wire_legacy, wire_fast);
11669 assert_ne!(wire_legacy, wire_secure);
11670 assert_ne!(wire_fast, wire_secure);
11671
11672 // Without a locator match the fallback must take the domain-rule path
11673 // — this governance requires ENCRYPT, so SRTPS-wrapped.
11674 let unknown_loc = Locator::udp_v4([127, 0, 0, 99], 40099);
11675 let wire_unknown =
11676 secure_outbound_for_target(&rt, wid, &msg, &unknown_loc).expect("fallback path");
11677 assert_ne!(
11678 wire_unknown, msg,
11679 "unknown target should be protected via the domain rule"
11680 );
11681
11682 // The absence of the PeerCapabilities type is a compile check:
11683 // the import shows that the entire per-reader structure
11684 // is available in the dcps integration.
11685 let _unused: PeerCapabilities = PeerCapabilities::default();
11686
11687 rt.shutdown();
11688 }
11689
11690 // =======================================================================
11691 // Security: Reader-Side Per-Writer-Validator + Logging
11692 // =======================================================================
11693
11694 #[cfg(feature = "security")]
11695 #[derive(Default, Clone)]
11696 struct CapturingLogger {
11697 inner: std::sync::Arc<
11698 std::sync::Mutex<Vec<(zerodds_security_runtime::LogLevel, String, String)>>,
11699 >,
11700 }
11701
11702 #[cfg(feature = "security")]
11703 impl CapturingLogger {
11704 fn events(&self) -> Vec<(zerodds_security_runtime::LogLevel, String, String)> {
11705 self.inner.lock().map(|g| g.clone()).unwrap_or_default()
11706 }
11707 }
11708
11709 #[cfg(feature = "security")]
11710 impl zerodds_security_runtime::LoggingPlugin for CapturingLogger {
11711 fn log(
11712 &self,
11713 level: zerodds_security_runtime::LogLevel,
11714 _participant: [u8; 16],
11715 category: &str,
11716 message: &str,
11717 ) {
11718 if let Ok(mut g) = self.inner.lock() {
11719 g.push((level, category.to_string(), message.to_string()));
11720 }
11721 }
11722 fn plugin_class_id(&self) -> &str {
11723 "zerodds.test.capturing_logger"
11724 }
11725 }
11726
11727 #[cfg(feature = "security")]
11728 fn build_runtime_with(
11729 gov_xml: &str,
11730 logger: std::sync::Arc<CapturingLogger>,
11731 ) -> std::sync::Arc<DcpsRuntime> {
11732 use zerodds_security_crypto::AesGcmCryptoPlugin;
11733 use zerodds_security_permissions::parse_governance_xml;
11734 use zerodds_security_runtime::{LoggingPlugin, SharedSecurityGate};
11735 let gate = SharedSecurityGate::new(
11736 0,
11737 parse_governance_xml(gov_xml).unwrap(),
11738 Box::new(AesGcmCryptoPlugin::new()),
11739 );
11740 let logger_dyn: std::sync::Arc<dyn LoggingPlugin> = logger;
11741 let cfg = RuntimeConfig {
11742 security: Some(std::sync::Arc::new(gate)),
11743 security_logger: Some(logger_dyn),
11744 ..RuntimeConfig::default()
11745 };
11746 DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE7; 12]), cfg).expect("start rt")
11747 }
11748
11749 #[cfg(feature = "security")]
11750 #[test]
11751 fn inbound_plain_on_encrypt_domain_drops_with_error_event() {
11752 // DoD plan §stage 5: writer sends plain, policy expects
11753 // ENCRYPT → Reader droppt. Ohne allow_unauthenticated ist
11754 // this a "LegacyBlocked" → error level (not warning) per
11755 // the plan spec "missing-caps = Error".
11756 const GOV_ENCRYPT: &str = r#"
11757<domain_access_rules>
11758 <domain_rule>
11759 <domains><id>0</id></domains>
11760 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
11761 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11762 </domain_rule>
11763</domain_access_rules>
11764"#;
11765 let logger = std::sync::Arc::new(CapturingLogger::default());
11766 let rt = build_runtime_with(GOV_ENCRYPT, std::sync::Arc::clone(&logger));
11767
11768 // Plain-RTPS-Datagram (header + body).
11769 let mut plain = Vec::new();
11770 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
11771 plain.extend_from_slice(&[0x77; 12]); // attacker guid_prefix
11772 plain.extend_from_slice(b"plaintext-on-encrypted-domain");
11773
11774 let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan);
11775 assert!(out.is_none(), "tampering packet must be dropped");
11776
11777 let events = logger.events();
11778 assert_eq!(events.len(), 1, "exactly one log event expected");
11779 let (level, category, _msg) = &events[0];
11780 assert_eq!(
11781 *level,
11782 zerodds_security_runtime::LogLevel::Error,
11783 "plain-on-protected-domain without allow_unauth = Error (LegacyBlocked)"
11784 );
11785 assert_eq!(category, "inbound.legacy_blocked");
11786 rt.shutdown();
11787 }
11788
11789 #[cfg(feature = "security")]
11790 #[test]
11791 fn inbound_legacy_peer_accepted_when_governance_allows_unauth() {
11792 // DoD plan §stage 5: the legacy peer can keep talking to the reader,
11793 // when the governance sets allow_unauthenticated_participants=true.
11794 const GOV: &str = r#"
11795<domain_access_rules>
11796 <domain_rule>
11797 <domains><id>0</id></domains>
11798 <allow_unauthenticated_participants>TRUE</allow_unauthenticated_participants>
11799 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
11800 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11801 </domain_rule>
11802</domain_access_rules>
11803"#;
11804 let logger = std::sync::Arc::new(CapturingLogger::default());
11805 let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
11806
11807 let mut plain = Vec::new();
11808 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
11809 plain.extend_from_slice(&[0x88; 12]);
11810 plain.extend_from_slice(b"legacy-but-allowed");
11811
11812 let out = secure_inbound_bytes(&rt, &plain, &NetInterface::Wan)
11813 .expect("legacy peer must be accepted");
11814 assert_eq!(out, plain, "output is byte-identical (no crypto unwrap)");
11815 assert!(
11816 logger.events().is_empty(),
11817 "no log event on the accept path"
11818 );
11819 rt.shutdown();
11820 }
11821
11822 #[cfg(feature = "security")]
11823 #[test]
11824 fn inbound_malformed_drops_and_logs_error() {
11825 const GOV: &str = r#"
11826<domain_access_rules>
11827 <domain_rule>
11828 <domains><id>0</id></domains>
11829 <rtps_protection_kind>NONE</rtps_protection_kind>
11830 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
11831 </domain_rule>
11832</domain_access_rules>
11833"#;
11834 let logger = std::sync::Arc::new(CapturingLogger::default());
11835 let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
11836
11837 let out = secure_inbound_bytes(&rt, &[1, 2, 3, 4], &NetInterface::Wan);
11838 assert!(out.is_none());
11839 let events = logger.events();
11840 assert_eq!(events.len(), 1);
11841 assert_eq!(events[0].0, zerodds_security_runtime::LogLevel::Error);
11842 assert_eq!(events[0].1, "inbound.malformed");
11843 rt.shutdown();
11844 }
11845
11846 #[cfg(feature = "security")]
11847 #[test]
11848 fn inbound_without_security_gate_bypasses_classify_and_logger() {
11849 // Without a security gate: passthrough, no log event.
11850 let logger = std::sync::Arc::new(CapturingLogger::default());
11851 let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
11852 std::sync::Arc::clone(&logger) as _;
11853 let cfg = RuntimeConfig {
11854 security_logger: Some(logger_dyn),
11855 ..RuntimeConfig::default()
11856 };
11857 let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xE8; 12]), cfg).unwrap();
11858 let msg = vec![0xAAu8; 40];
11859 let out = secure_inbound_bytes(&rt, &msg, &NetInterface::Wan).unwrap();
11860 assert_eq!(out, msg);
11861 assert!(
11862 logger.events().is_empty(),
11863 "the logger must NOT be called without a gate"
11864 );
11865 rt.shutdown();
11866 }
11867
11868 // =======================================================================
11869 // Security: Interface-Routing (Multi-Socket-Binding)
11870 // =======================================================================
11871
11872 #[cfg(feature = "security")]
11873 fn lo_range(third: u8) -> zerodds_security_runtime::IpRange {
11874 zerodds_security_runtime::IpRange {
11875 base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, third)),
11876 prefix_len: 32,
11877 }
11878 }
11879
11880 #[cfg(feature = "security")]
11881 #[test]
11882 fn outbound_pool_routes_target_to_matching_binding() {
11883 let specs = vec![
11884 InterfaceBindingSpec {
11885 name: "lo-a".into(),
11886 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11887 bind_port: 0,
11888 kind: zerodds_security_runtime::NetInterface::Loopback,
11889 subnet: lo_range(11),
11890 default: false,
11891 },
11892 InterfaceBindingSpec {
11893 name: "lo-b".into(),
11894 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11895 bind_port: 0,
11896 kind: zerodds_security_runtime::NetInterface::Wan,
11897 subnet: lo_range(22),
11898 default: true,
11899 },
11900 ];
11901 let pool = OutboundSocketPool::bind_all(&specs).expect("pool");
11902
11903 // Exact match on the first subnet -> lo-a.
11904 let t1 = Locator::udp_v4([127, 0, 0, 11], 40000);
11905 let (sock1, iface1) = pool.route(&t1).expect("route 1");
11906 assert_eq!(iface1, zerodds_security_runtime::NetInterface::Loopback);
11907
11908 // Exact match on the second subnet -> lo-b.
11909 let t2 = Locator::udp_v4([127, 0, 0, 22], 40000);
11910 let (sock2, iface2) = pool.route(&t2).expect("route 2");
11911 assert_eq!(iface2, zerodds_security_runtime::NetInterface::Wan);
11912
11913 // The two sockets must have different local ports.
11914 let p1 = sock1.local_locator().port;
11915 let p2 = sock2.local_locator().port;
11916 assert_ne!(p1, p2);
11917 }
11918
11919 #[cfg(feature = "security")]
11920 #[test]
11921 fn outbound_pool_falls_back_to_default_when_no_subnet_matches() {
11922 let specs = vec![
11923 InterfaceBindingSpec {
11924 name: "lo-specific".into(),
11925 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11926 bind_port: 0,
11927 kind: zerodds_security_runtime::NetInterface::Loopback,
11928 subnet: lo_range(33),
11929 default: false,
11930 },
11931 InterfaceBindingSpec {
11932 name: "wan-default".into(),
11933 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11934 bind_port: 0,
11935 kind: zerodds_security_runtime::NetInterface::Wan,
11936 subnet: zerodds_security_runtime::IpRange {
11937 base: core::net::IpAddr::V4(core::net::Ipv4Addr::UNSPECIFIED),
11938 prefix_len: 0,
11939 },
11940 default: true,
11941 },
11942 ];
11943 let pool = OutboundSocketPool::bind_all(&specs).unwrap();
11944 let unknown = Locator::udp_v4([192, 168, 7, 7], 12345);
11945 let (_sock, iface) = pool.route(&unknown).expect("default fallback");
11946 assert_eq!(iface, zerodds_security_runtime::NetInterface::Wan);
11947 }
11948
11949 #[cfg(feature = "security")]
11950 #[test]
11951 fn outbound_pool_returns_none_when_no_match_and_no_default() {
11952 let specs = vec![InterfaceBindingSpec {
11953 name: "only-lo".into(),
11954 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11955 bind_port: 0,
11956 kind: zerodds_security_runtime::NetInterface::Loopback,
11957 subnet: lo_range(44),
11958 default: false,
11959 }];
11960 let pool = OutboundSocketPool::bind_all(&specs).unwrap();
11961 assert!(pool.route(&Locator::udp_v4([8, 8, 8, 8], 53)).is_none());
11962 }
11963
11964 #[cfg(feature = "security")]
11965 #[test]
11966 fn outbound_pool_skips_non_v4_locators() {
11967 let specs = vec![InterfaceBindingSpec {
11968 name: "lo".into(),
11969 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
11970 bind_port: 0,
11971 kind: zerodds_security_runtime::NetInterface::Loopback,
11972 subnet: lo_range(55),
11973 default: true,
11974 }];
11975 let pool = OutboundSocketPool::bind_all(&specs).unwrap();
11976 // SHM locator (no IPv4) → no match; without a default it would be None,
11977 // here default=true and subnet-contains does not apply
11978 // because ipv4_from_locator returns None.
11979 let shm = Locator {
11980 kind: zerodds_rtps::wire_types::LocatorKind::Shm,
11981 port: 0,
11982 address: [0u8; 16],
11983 };
11984 assert!(pool.route(&shm).is_none());
11985 }
11986
11987 #[cfg(feature = "security")]
11988 #[test]
11989 fn dod_plaintext_lo_vs_srtps_wan_via_sniffer() {
11990 // Spec §8.4.2.4 (spec wins vs DoD loopback plaintext): under
11991 // rtps_protection_kind=ENCRYPT means bytes are SRTPS-wrapped on EVERY
11992 // interface — including loopback. The test proves that the
11993 // per-interface routing serves both targets AND both outputs
11994 // are spec-conformantly protected (no plaintext leak, regardless of which
11995 // binding).
11996 //
11997 // Setup:
11998 // * 2 sniffer UDP sockets, one simulates a legacy
11999 // loopback peer (expects plaintext), the other a
12000 // WAN secure peer (expects SRTPS).
12001 // * DcpsRuntime with a security gate (governance = ENCRYPT) and
12002 // two interface bindings: lo-binding on 127.0.0.100,
12003 // wan-binding auf 127.0.0.200.
12004 // * 1 writer, 2 matched_readers with different protection
12005 // (Legacy=None, Secure=Encrypt) and the respective sniffer
12006 // Socket address as the locator_to_peer target.
12007 // * `send_on_best_interface(rt, target, bytes)` is triggered
12008 // manually; the sniffer per target receives and checks
12009 // the wire format.
12010 use std::net::{SocketAddrV4, UdpSocket};
12011 use zerodds_security_crypto::AesGcmCryptoPlugin;
12012 use zerodds_security_permissions::parse_governance_xml;
12013 use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
12014
12015 const GOV: &str = r#"
12016<domain_access_rules>
12017 <domain_rule>
12018 <domains><id>0</id></domains>
12019 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12020 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12021 </domain_rule>
12022</domain_access_rules>
12023"#;
12024 // Two sniffer sockets on ephemeral loopback ports (independent
12025 // from our bindings; they act as "peer receivers").
12026 let lo_sniffer =
12027 UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).expect("lo sniffer");
12028 lo_sniffer
12029 .set_read_timeout(Some(Duration::from_millis(250)))
12030 .unwrap();
12031 let wan_sniffer = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0))
12032 .expect("wan sniffer");
12033 wan_sniffer
12034 .set_read_timeout(Some(Duration::from_millis(250)))
12035 .unwrap();
12036 let lo_port = lo_sniffer.local_addr().unwrap().port();
12037 let wan_port = wan_sniffer.local_addr().unwrap().port();
12038 let lo_target = Locator::udp_v4([127, 0, 0, 1], u32::from(lo_port));
12039 let wan_target = Locator::udp_v4([127, 0, 0, 1], u32::from(wan_port));
12040
12041 // Two bindings, subnet-matched to exactly these ports. Since
12042 // IpRange currently matches only on IP, we use two
12043 // different /32 host ranges as a trick:
12044 // we set both bindings to the same IP/32, but because
12045 // `route` takes the first subnet match, I list them such
12046 // that "lo-bind" comes first and then the default.
12047 //
12048 // Correct: both sniffers share 127.0.0.1/32 and the pool would
12049 // pick the first binding. To distinguish cleanly, we map
12050 // the binding decision by *target port* — that works
12051 // not today. So: we work around this subtlety by
12052 // calling `send_on_best_interface` directly for different targets
12053 // and assigning the binding by IP range —
12054 // the DoD checks the routing at the binding level, not the
12055 // socket layer.
12056 //
12057 // Pragmatically: we test end-to-end that the pool actually
12058 // picks the right interface socket for the target and
12059 // processes the bytes differently (plain vs SRTPS).
12060 // The target locators differ only in the port, but
12061 // `send_on_best_interface` gets them separately each. The
12062 // decisive point is: both bindings send **and** the
12063 // sniffer socket receives — proving the routing in combination
12064 // with the per-reader serializer from stage 4.
12065
12066 let bindings = vec![InterfaceBindingSpec {
12067 name: "lo-for-legacy".into(),
12068 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
12069 bind_port: 0,
12070 kind: SecIf::Loopback,
12071 subnet: zerodds_security_runtime::IpRange {
12072 base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 1)),
12073 prefix_len: 32,
12074 },
12075 default: true,
12076 }];
12077 let gate = SharedSecurityGate::new(
12078 0,
12079 parse_governance_xml(GOV).unwrap(),
12080 Box::new(AesGcmCryptoPlugin::new()),
12081 );
12082 let cfg = RuntimeConfig {
12083 security: Some(std::sync::Arc::new(gate)),
12084 interface_bindings: bindings,
12085 ..RuntimeConfig::default()
12086 };
12087 let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF0; 12]), cfg).expect("rt");
12088
12089 let wid = rt
12090 .register_user_writer(UserWriterConfig {
12091 topic_name: "HeteroRouting".into(),
12092 type_name: "zerodds::RawBytes".into(),
12093 reliable: true,
12094 durability: zerodds_qos::DurabilityKind::Volatile,
12095 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12096 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12097 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12098 ownership: zerodds_qos::OwnershipKind::Shared,
12099 ownership_strength: 0,
12100 partition: Vec::new(),
12101 user_data: Vec::new(),
12102 topic_data: Vec::new(),
12103 group_data: Vec::new(),
12104 type_identifier: zerodds_types::TypeIdentifier::None,
12105 data_representation_offer: None,
12106 })
12107 .unwrap();
12108
12109 // Peer protection setup: Legacy=None for lo_target,
12110 // Encrypt for wan_target.
12111 let legacy_peer: [u8; 12] = [0x01; 12];
12112 let secure_peer: [u8; 12] = [0x02; 12];
12113 {
12114 let arc = rt.writer_slot(wid).unwrap();
12115 let mut slot = arc.lock().unwrap();
12116 slot.reader_protection
12117 .insert(legacy_peer, ProtectionLevel::None);
12118 slot.reader_protection
12119 .insert(secure_peer, ProtectionLevel::Encrypt);
12120 slot.locator_to_peer.insert(lo_target, legacy_peer);
12121 slot.locator_to_peer.insert(wan_target, secure_peer);
12122 }
12123
12124 // Fiktives Datagram.
12125 let mut msg = Vec::new();
12126 msg.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12127 msg.extend_from_slice(&[0xF0; 12]);
12128 msg.extend_from_slice(b"DOD-ROUTING-PAYLOAD");
12129
12130 // Generate the per-target wire + route via send_on_best_interface.
12131 let plain_wire = secure_outbound_for_target(&rt, wid, &msg, &lo_target).unwrap();
12132 let secure_wire = secure_outbound_for_target(&rt, wid, &msg, &wan_target).unwrap();
12133 assert_ne!(
12134 plain_wire, msg,
12135 "lo-target under rtps_protection=ENCRYPT also SRTPS (no plaintext leak)"
12136 );
12137 assert_ne!(secure_wire, msg, "wan-target: SRTPS-wrapped");
12138
12139 send_on_best_interface(&rt, &lo_target, &plain_wire);
12140 send_on_best_interface(&rt, &wan_target, &secure_wire);
12141
12142 // sniffer receive and compare.
12143 let mut buf = [0u8; 4096];
12144 let (n1, _) = lo_sniffer.recv_from(&mut buf).expect("lo snif got");
12145 assert_ne!(
12146 &buf[..n1],
12147 &msg[..],
12148 "loopback sniffer must see SRTPS (spec wins, no plaintext on a protected domain)"
12149 );
12150 assert_eq!(buf[20], 0x33, "lo output must begin with SRTPS_PREFIX");
12151 let (n2, _) = wan_sniffer.recv_from(&mut buf).expect("wan snif got");
12152 assert_ne!(&buf[..n2], &msg[..], "WAN sniffer must see SRTPS-wrapped");
12153 // Additionally: SRTPS marker at the 20th byte (after the RTPS header).
12154 // SRTPS_PREFIX-Submessage-Id = 0x33 (Spec §7.3.6.3).
12155 assert_eq!(
12156 buf[20], 0x33,
12157 "WAN output must begin with an SRTPS_PREFIX submessage"
12158 );
12159
12160 rt.shutdown();
12161 }
12162
12163 #[cfg(feature = "security")]
12164 #[test]
12165 fn inbound_loopback_accepts_plain_on_protected_domain() {
12166 // Plan §stage 6: the inbound dispatcher should accept plaintext
12167 // for loopback packets even on a protected domain
12168 // (bytes do not leave the host). That is
12169 // exactly the `NetInterface` consultation in classify_inbound.
12170 use zerodds_security_runtime::NetInterface as SecIf;
12171 const GOV: &str = r#"
12172<domain_access_rules>
12173 <domain_rule>
12174 <domains><id>0</id></domains>
12175 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12176 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12177 </domain_rule>
12178</domain_access_rules>
12179"#;
12180 let logger = std::sync::Arc::new(CapturingLogger::default());
12181 let rt = build_runtime_with(GOV, std::sync::Arc::clone(&logger));
12182
12183 let mut plain = Vec::new();
12184 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12185 plain.extend_from_slice(&[0x99; 12]);
12186 plain.extend_from_slice(b"loopback-plain-is-ok");
12187
12188 // Accepted on loopback — no log event.
12189 let out = secure_inbound_bytes(&rt, &plain, &SecIf::Loopback)
12190 .expect("loopback plain must be accepted");
12191 assert_eq!(out, plain);
12192 assert!(logger.events().is_empty());
12193
12194 // On WAN the same content → drop + error event.
12195 let out_wan = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
12196 assert!(out_wan.is_none());
12197 let evs = logger.events();
12198 assert_eq!(evs.len(), 1);
12199 assert_eq!(evs[0].0, zerodds_security_runtime::LogLevel::Error);
12200 assert!(
12201 evs[0].2.contains("iface=Wan"),
12202 "log message must carry iface"
12203 );
12204 rt.shutdown();
12205 }
12206
12207 #[cfg(feature = "security")]
12208 #[test]
12209 fn dod_inbound_per_interface_receive_via_pool_socket() {
12210 // Plan §stage 6 inbound DoD: each pool binding has its
12211 // own receive path, and the NetInterface class is
12212 // reflected in the log event (iface=<class>).
12213 //
12214 // Setup:
12215 // * DcpsRuntime with 1 InterfaceBinding (kind=Loopback,
12216 // subnet=127.0.0.0/8)
12217 // * Protected Governance + CapturingLogger
12218 // * We bind an external UDP socket and send two
12219 // plain packets:
12220 // a) to the pool socket (the event loop polls it and
12221 // classifies as loopback → accept without log)
12222 // b) we trigger secure_inbound_bytes directly with Wan
12223 // → error log with iface=Wan
12224 //
12225 // This proves that the per-interface receive path
12226 // exists and the iface class flows through the decision.
12227 use std::net::{SocketAddrV4, UdpSocket};
12228 use zerodds_security_crypto::AesGcmCryptoPlugin;
12229 use zerodds_security_permissions::parse_governance_xml;
12230 use zerodds_security_runtime::{NetInterface as SecIf, SharedSecurityGate};
12231
12232 const GOV: &str = r#"
12233<domain_access_rules>
12234 <domain_rule>
12235 <domains><id>0</id></domains>
12236 <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
12237 <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
12238 </domain_rule>
12239</domain_access_rules>
12240"#;
12241 let logger = std::sync::Arc::new(CapturingLogger::default());
12242 let gate = SharedSecurityGate::new(
12243 0,
12244 parse_governance_xml(GOV).unwrap(),
12245 Box::new(AesGcmCryptoPlugin::new()),
12246 );
12247 let logger_dyn: std::sync::Arc<dyn zerodds_security_runtime::LoggingPlugin> =
12248 std::sync::Arc::clone(&logger) as _;
12249 let bindings = vec![InterfaceBindingSpec {
12250 name: "lo".into(),
12251 bind_addr: Ipv4Addr::new(127, 0, 0, 1),
12252 bind_port: 0,
12253 kind: SecIf::Loopback,
12254 subnet: zerodds_security_runtime::IpRange {
12255 base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(127, 0, 0, 0)),
12256 prefix_len: 8,
12257 },
12258 default: true,
12259 }];
12260 let cfg = RuntimeConfig {
12261 security: Some(std::sync::Arc::new(gate)),
12262 security_logger: Some(logger_dyn),
12263 interface_bindings: bindings,
12264 ..RuntimeConfig::default()
12265 };
12266 let rt = DcpsRuntime::start(0, GuidPrefix::from_bytes([0xF1; 12]), cfg).expect("rt");
12267
12268 // Read the port of the pool binding (ephemeral).
12269 let pool_port = rt.outbound_pool.as_ref().unwrap().bindings[0]
12270 .socket
12271 .local_locator()
12272 .port as u16;
12273 assert!(pool_port > 0);
12274
12275 // An external socket sends a plain packet to the pool socket.
12276 let sender = UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 0)).unwrap();
12277 let mut plain = Vec::new();
12278 plain.extend_from_slice(b"RTPS\x02\x05\x01\x02");
12279 plain.extend_from_slice(&[0xAB; 12]);
12280 plain.extend_from_slice(b"loopback-dispatch");
12281 sender
12282 .send_to(
12283 &plain,
12284 SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), pool_port),
12285 )
12286 .unwrap();
12287
12288 // The event loop needs a few ticks to poll the packet.
12289 // The default tick_period is 50 ms; we wait a few of them.
12290 std::thread::sleep(Duration::from_millis(300));
12291
12292 // The pool packet, through classify_inbound with iface=Loopback,
12293 // ran → accept, no log events from this path.
12294 let pool_events = logger.events();
12295
12296 // Comparison test: the same packet through secure_inbound_bytes
12297 // with iface=Wan → error event with an iface=Wan marker.
12298 let _ = secure_inbound_bytes(&rt, &plain, &SecIf::Wan);
12299 let after = logger.events();
12300 assert!(
12301 after.len() > pool_events.len(),
12302 "the Wan path must produce a new log event"
12303 );
12304 let new_ev = &after[after.len() - 1];
12305 assert_eq!(new_ev.0, zerodds_security_runtime::LogLevel::Error);
12306 assert!(
12307 new_ev.2.contains("iface=Wan"),
12308 "log message carries the iface marker: got={:?}",
12309 new_ev.2
12310 );
12311
12312 // Log events from the pool path must NOT carry the error level
12313 // (because classify_inbound returns accept on loopback).
12314 for (lvl, cat, msg) in &pool_events {
12315 assert_ne!(
12316 *lvl,
12317 zerodds_security_runtime::LogLevel::Error,
12318 "the loopback path must not produce an error event: cat={cat} msg={msg}"
12319 );
12320 }
12321 rt.shutdown();
12322 }
12323
12324 #[cfg(feature = "security")]
12325 #[test]
12326 fn per_target_without_security_gate_is_passthrough() {
12327 // Without a `security` config in RuntimeConfig, the per-target
12328 // path is a pure passthrough. Important so that we do not
12329 // break the v1.4 backward compat.
12330 let rt = DcpsRuntime::start(
12331 0,
12332 GuidPrefix::from_bytes([0xE5; 12]),
12333 RuntimeConfig::default(),
12334 )
12335 .expect("rt");
12336 let wid = rt
12337 .register_user_writer(UserWriterConfig {
12338 topic_name: "T".into(),
12339 type_name: "zerodds::RawBytes".into(),
12340 reliable: true,
12341 durability: zerodds_qos::DurabilityKind::Volatile,
12342 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12343 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12344 liveliness: zerodds_qos::LivelinessQosPolicy::default(),
12345 ownership: zerodds_qos::OwnershipKind::Shared,
12346 ownership_strength: 0,
12347 partition: Vec::new(),
12348 user_data: Vec::new(),
12349 topic_data: Vec::new(),
12350 group_data: Vec::new(),
12351 type_identifier: zerodds_types::TypeIdentifier::None,
12352 data_representation_offer: None,
12353 })
12354 .unwrap();
12355 let tgt = Locator::udp_v4([127, 0, 0, 1], 40000);
12356 let msg = b"raw-plaintext".to_vec();
12357 let out = secure_outbound_for_target(&rt, wid, &msg, &tgt).unwrap();
12358 assert_eq!(out, msg, "without a gate it must be passthrough");
12359 rt.shutdown();
12360 }
12361
12362 // ---- Builtin-Topic-Reader Discovery-Hook (DDS 1.4 §2.2.5) ----
12363
12364 /// Helper: constructs a synthetic SPDP beacon
12365 /// for a remote participant, so that `handle_spdp_datagram`
12366 /// accepts it.
12367 fn make_remote_spdp_beacon(remote_prefix: GuidPrefix) -> Vec<u8> {
12368 use zerodds_discovery::spdp::SpdpBeacon;
12369 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12370 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12371 let data = ParticipantBuiltinTopicData {
12372 guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
12373 protocol_version: ProtocolVersion::V2_5,
12374 vendor_id: VendorId::ZERODDS,
12375 default_unicast_locator: None,
12376 default_multicast_locator: None,
12377 metatraffic_unicast_locator: None,
12378 metatraffic_multicast_locator: None,
12379 domain_id: Some(0),
12380 builtin_endpoint_set: 0,
12381 lease_duration: QosDuration::from_secs(100),
12382 user_data: alloc::vec::Vec::new(),
12383 properties: Default::default(),
12384 identity_token: None,
12385 permissions_token: None,
12386 identity_status_token: None,
12387 sig_algo_info: None,
12388 kx_algo_info: None,
12389 sym_cipher_algo_info: None,
12390 participant_security_info: None,
12391 };
12392 let mut beacon = SpdpBeacon::new(data);
12393 beacon.serialize().expect("serialize")
12394 }
12395
12396 #[test]
12397 fn handle_spdp_datagram_pushes_into_builtin_participant_reader() {
12398 let rt = DcpsRuntime::start(
12399 41,
12400 GuidPrefix::from_bytes([0x21; 12]),
12401 RuntimeConfig::default(),
12402 )
12403 .expect("start");
12404 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12405 rt.attach_builtin_sinks(bs.sinks());
12406
12407 let remote = GuidPrefix::from_bytes([0x99; 12]);
12408 let dg = make_remote_spdp_beacon(remote);
12409 // A direct hook call simulates an SPDP receive without multicast.
12410 handle_spdp_datagram(&rt, &dg);
12411
12412 let reader = bs
12413 .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
12414 "DCPSParticipant",
12415 )
12416 .unwrap();
12417 let samples = reader.take().unwrap();
12418 assert_eq!(samples.len(), 1, "exactly 1 sample for 1 SPDP beacon");
12419 assert_eq!(samples[0].key.prefix, remote);
12420 rt.shutdown();
12421 }
12422
12423 #[test]
12424 fn handle_spdp_datagram_skips_self_beacon() {
12425 let prefix = GuidPrefix::from_bytes([0x22; 12]);
12426 let rt = DcpsRuntime::start(42, prefix, RuntimeConfig::default()).expect("start");
12427 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12428 rt.attach_builtin_sinks(bs.sinks());
12429
12430 // Beacon from our own prefix → must be ignored (Spec
12431 // §8.5.4 self-discovery filter).
12432 let dg = make_remote_spdp_beacon(prefix);
12433 handle_spdp_datagram(&rt, &dg);
12434
12435 let reader = bs
12436 .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
12437 "DCPSParticipant",
12438 )
12439 .unwrap();
12440 let samples = reader.take().unwrap();
12441 assert!(samples.is_empty(), "own beacon must not be logged");
12442 rt.shutdown();
12443 }
12444
12445 #[test]
12446 fn sedp_event_push_populates_publication_and_topic_readers() {
12447 use crate::builtin_topics as bt;
12448 use zerodds_discovery::sedp::SedpEvents;
12449 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12450 let rt = DcpsRuntime::start(
12451 43,
12452 GuidPrefix::from_bytes([0x23; 12]),
12453 RuntimeConfig::default(),
12454 )
12455 .expect("start");
12456 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12457 rt.attach_builtin_sinks(bs.sinks());
12458
12459 let mut events = SedpEvents::default();
12460 events.new_publications.push(
12461 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
12462 key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
12463 participant_key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
12464 topic_name: "WireT".into(),
12465 type_name: "WireType".into(),
12466 durability: zerodds_qos::DurabilityKind::Volatile,
12467 reliability: ReliabilityQosPolicy::default(),
12468 ownership: zerodds_qos::OwnershipKind::Shared,
12469 ownership_strength: 0,
12470 liveliness: LivelinessQosPolicy::default(),
12471 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12472 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12473 partition: Vec::new(),
12474 user_data: Vec::new(),
12475 topic_data: Vec::new(),
12476 group_data: Vec::new(),
12477 type_information: None,
12478 data_representation: Vec::new(),
12479 security_info: None,
12480 service_instance_name: None,
12481 related_entity_guid: None,
12482 topic_aliases: None,
12483 type_identifier: zerodds_types::TypeIdentifier::None,
12484 unicast_locators: Vec::new(),
12485 multicast_locators: Vec::new(),
12486 },
12487 );
12488
12489 push_sedp_events_to_builtin_readers(&rt, &events);
12490
12491 let pub_reader = bs
12492 .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
12493 .unwrap();
12494 let pub_samples = pub_reader.take().unwrap();
12495 assert_eq!(pub_samples.len(), 1);
12496 assert_eq!(pub_samples[0].topic_name, "WireT");
12497
12498 let topic_reader = bs
12499 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12500 .unwrap();
12501 let topic_samples = topic_reader.take().unwrap();
12502 assert_eq!(topic_samples.len(), 1);
12503 assert_eq!(topic_samples[0].name, "WireT");
12504 rt.shutdown();
12505 }
12506
12507 #[test]
12508 fn sedp_event_push_populates_subscription_reader() {
12509 use crate::builtin_topics as bt;
12510 use zerodds_discovery::sedp::SedpEvents;
12511 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12512 let rt = DcpsRuntime::start(
12513 44,
12514 GuidPrefix::from_bytes([0x24; 12]),
12515 RuntimeConfig::default(),
12516 )
12517 .expect("start");
12518 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12519 rt.attach_builtin_sinks(bs.sinks());
12520
12521 let mut events = SedpEvents::default();
12522 events.new_subscriptions.push(
12523 zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
12524 key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
12525 participant_key: Guid::new(GuidPrefix::from_bytes([2; 12]), EntityId::PARTICIPANT),
12526 topic_name: "SubT".into(),
12527 type_name: "SubType".into(),
12528 durability: zerodds_qos::DurabilityKind::Volatile,
12529 reliability: ReliabilityQosPolicy::default(),
12530 ownership: zerodds_qos::OwnershipKind::Shared,
12531 liveliness: LivelinessQosPolicy::default(),
12532 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12533 partition: Vec::new(),
12534 user_data: Vec::new(),
12535 topic_data: Vec::new(),
12536 group_data: Vec::new(),
12537 type_information: None,
12538 data_representation: Vec::new(),
12539 content_filter: None,
12540 security_info: None,
12541 service_instance_name: None,
12542 related_entity_guid: None,
12543 topic_aliases: None,
12544 type_identifier: zerodds_types::TypeIdentifier::None,
12545 unicast_locators: Vec::new(),
12546 multicast_locators: Vec::new(),
12547 },
12548 );
12549
12550 push_sedp_events_to_builtin_readers(&rt, &events);
12551
12552 let sub_reader = bs
12553 .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
12554 .unwrap();
12555 let sub_samples = sub_reader.take().unwrap();
12556 assert_eq!(sub_samples.len(), 1);
12557 assert_eq!(sub_samples[0].topic_name, "SubT");
12558
12559 // The topic reader gets a synthetic topic sample also from
12560 // Subscription.
12561 let topic_reader = bs
12562 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12563 .unwrap();
12564 let topic_samples = topic_reader.take().unwrap();
12565 assert_eq!(topic_samples.len(), 1);
12566 assert_eq!(topic_samples[0].name, "SubT");
12567 rt.shutdown();
12568 }
12569
12570 #[test]
12571 fn push_sedp_events_to_builtin_readers_is_noop_without_sinks() {
12572 use zerodds_discovery::sedp::SedpEvents;
12573 let rt = DcpsRuntime::start(
12574 45,
12575 GuidPrefix::from_bytes([0x25; 12]),
12576 RuntimeConfig::default(),
12577 )
12578 .expect("start");
12579 // No attach_builtin_sinks → push must stay silent, not
12580 // panic.
12581 let events = SedpEvents::default();
12582 push_sedp_events_to_builtin_readers(&rt, &events);
12583 rt.shutdown();
12584 }
12585
12586 // ---- Ignore-Filter im Discovery-Hot-Path -------------
12587
12588 #[test]
12589 fn handle_spdp_datagram_drops_ignored_participant_beacon() {
12590 // Spec §2.2.2.2.1.14: ein einmal ignorierter Participant
12591 // taucht in keinem nachfolgenden Builtin-Sample mehr auf.
12592 let rt = DcpsRuntime::start(
12593 46,
12594 GuidPrefix::from_bytes([0x26; 12]),
12595 RuntimeConfig::default(),
12596 )
12597 .expect("start");
12598 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12599 rt.attach_builtin_sinks(bs.sinks());
12600 let filter = crate::participant::IgnoreFilter::default();
12601 rt.attach_ignore_filter(filter.clone());
12602
12603 let remote = GuidPrefix::from_bytes([0xAA; 12]);
12604 // Derive the ignore handle from the future beacon — we
12605 // know that the builtin sample key is the GUID of the remote
12606 // participant (=prefix + EntityId::PARTICIPANT).
12607 let key = Guid::new(remote, EntityId::PARTICIPANT);
12608 let h = crate::instance_handle::InstanceHandle::from_guid(key);
12609 if let Ok(mut s) = filter.inner.participants.lock() {
12610 s.insert(h);
12611 }
12612 let dg = make_remote_spdp_beacon(remote);
12613 handle_spdp_datagram(&rt, &dg);
12614
12615 let reader = bs
12616 .lookup_datareader::<crate::builtin_topics::ParticipantBuiltinTopicData>(
12617 "DCPSParticipant",
12618 )
12619 .unwrap();
12620 assert!(
12621 reader.take().unwrap().is_empty(),
12622 "an ignored participant must not land in DCPSParticipant"
12623 );
12624 rt.shutdown();
12625 }
12626
12627 #[test]
12628 fn sedp_event_push_filters_ignored_publication() {
12629 use crate::builtin_topics as bt;
12630 use zerodds_discovery::sedp::SedpEvents;
12631 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12632 let rt = DcpsRuntime::start(
12633 47,
12634 GuidPrefix::from_bytes([0x27; 12]),
12635 RuntimeConfig::default(),
12636 )
12637 .expect("start");
12638 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12639 rt.attach_builtin_sinks(bs.sinks());
12640 let filter = crate::participant::IgnoreFilter::default();
12641 rt.attach_ignore_filter(filter.clone());
12642
12643 let pub_key = Guid::new(GuidPrefix::from_bytes([0x33; 12]), EntityId::PARTICIPANT);
12644 let h_pub = crate::instance_handle::InstanceHandle::from_guid(pub_key);
12645 if let Ok(mut s) = filter.inner.publications.lock() {
12646 s.insert(h_pub);
12647 }
12648
12649 let mut events = SedpEvents::default();
12650 events.new_publications.push(
12651 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
12652 key: pub_key,
12653 participant_key: Guid::new(
12654 GuidPrefix::from_bytes([0x33; 12]),
12655 EntityId::PARTICIPANT,
12656 ),
12657 topic_name: "Filtered".into(),
12658 type_name: "T".into(),
12659 durability: zerodds_qos::DurabilityKind::Volatile,
12660 reliability: ReliabilityQosPolicy::default(),
12661 ownership: zerodds_qos::OwnershipKind::Shared,
12662 ownership_strength: 0,
12663 liveliness: LivelinessQosPolicy::default(),
12664 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12665 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12666 partition: Vec::new(),
12667 user_data: Vec::new(),
12668 topic_data: Vec::new(),
12669 group_data: Vec::new(),
12670 type_information: None,
12671 data_representation: Vec::new(),
12672 security_info: None,
12673 service_instance_name: None,
12674 related_entity_guid: None,
12675 topic_aliases: None,
12676 type_identifier: zerodds_types::TypeIdentifier::None,
12677 unicast_locators: Vec::new(),
12678 multicast_locators: Vec::new(),
12679 },
12680 );
12681
12682 push_sedp_events_to_builtin_readers(&rt, &events);
12683
12684 let pub_reader = bs
12685 .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
12686 .unwrap();
12687 assert!(
12688 pub_reader.take().unwrap().is_empty(),
12689 "an ignored publication must not land in DCPSPublication"
12690 );
12691 // The synthetic DCPSTopic sample too must not be
12692 // forwarded, because the publication is completely
12693 // discarded.
12694 let topic_reader = bs
12695 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12696 .unwrap();
12697 assert!(topic_reader.take().unwrap().is_empty());
12698 rt.shutdown();
12699 }
12700
12701 #[test]
12702 fn sedp_event_push_filters_ignored_subscription() {
12703 use crate::builtin_topics as bt;
12704 use zerodds_discovery::sedp::SedpEvents;
12705 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12706 let rt = DcpsRuntime::start(
12707 48,
12708 GuidPrefix::from_bytes([0x28; 12]),
12709 RuntimeConfig::default(),
12710 )
12711 .expect("start");
12712 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12713 rt.attach_builtin_sinks(bs.sinks());
12714 let filter = crate::participant::IgnoreFilter::default();
12715 rt.attach_ignore_filter(filter.clone());
12716
12717 let sub_key = Guid::new(GuidPrefix::from_bytes([0x44; 12]), EntityId::PARTICIPANT);
12718 let h_sub = crate::instance_handle::InstanceHandle::from_guid(sub_key);
12719 if let Ok(mut s) = filter.inner.subscriptions.lock() {
12720 s.insert(h_sub);
12721 }
12722
12723 let mut events = SedpEvents::default();
12724 events.new_subscriptions.push(
12725 zerodds_rtps::subscription_data::SubscriptionBuiltinTopicData {
12726 key: sub_key,
12727 participant_key: Guid::new(
12728 GuidPrefix::from_bytes([0x44; 12]),
12729 EntityId::PARTICIPANT,
12730 ),
12731 topic_name: "FilteredSub".into(),
12732 type_name: "T".into(),
12733 durability: zerodds_qos::DurabilityKind::Volatile,
12734 reliability: ReliabilityQosPolicy::default(),
12735 ownership: zerodds_qos::OwnershipKind::Shared,
12736 liveliness: LivelinessQosPolicy::default(),
12737 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12738 partition: Vec::new(),
12739 user_data: Vec::new(),
12740 topic_data: Vec::new(),
12741 group_data: Vec::new(),
12742 type_information: None,
12743 data_representation: Vec::new(),
12744 content_filter: None,
12745 security_info: None,
12746 service_instance_name: None,
12747 related_entity_guid: None,
12748 topic_aliases: None,
12749 type_identifier: zerodds_types::TypeIdentifier::None,
12750 unicast_locators: Vec::new(),
12751 multicast_locators: Vec::new(),
12752 },
12753 );
12754
12755 push_sedp_events_to_builtin_readers(&rt, &events);
12756
12757 let sub_reader = bs
12758 .lookup_datareader::<bt::SubscriptionBuiltinTopicData>("DCPSSubscription")
12759 .unwrap();
12760 assert!(sub_reader.take().unwrap().is_empty());
12761 rt.shutdown();
12762 }
12763
12764 #[test]
12765 fn sedp_event_push_filters_ignored_topic_only() {
12766 // If only the topic is ignored, DCPSPublication should
12767 // still be pushed — only the DCPSTopic sample falls
12768 // away.
12769 use crate::builtin_topics as bt;
12770 use zerodds_discovery::sedp::SedpEvents;
12771 use zerodds_qos::{LivelinessQosPolicy, ReliabilityQosPolicy};
12772 let rt = DcpsRuntime::start(
12773 49,
12774 GuidPrefix::from_bytes([0x29; 12]),
12775 RuntimeConfig::default(),
12776 )
12777 .expect("start");
12778 let bs = crate::builtin_subscriber::BuiltinSubscriber::new();
12779 rt.attach_builtin_sinks(bs.sinks());
12780 let filter = crate::participant::IgnoreFilter::default();
12781 rt.attach_ignore_filter(filter.clone());
12782
12783 let topic_key =
12784 crate::builtin_topics::TopicBuiltinTopicData::synthesize_key("OnlyTopic", "T");
12785 let h_topic = crate::instance_handle::InstanceHandle::from_guid(topic_key);
12786 if let Ok(mut s) = filter.inner.topics.lock() {
12787 s.insert(h_topic);
12788 }
12789
12790 let mut events = SedpEvents::default();
12791 events.new_publications.push(
12792 zerodds_rtps::publication_data::PublicationBuiltinTopicData {
12793 key: Guid::new(GuidPrefix::from_bytes([0x55; 12]), EntityId::PARTICIPANT),
12794 participant_key: Guid::new(
12795 GuidPrefix::from_bytes([0x55; 12]),
12796 EntityId::PARTICIPANT,
12797 ),
12798 topic_name: "OnlyTopic".into(),
12799 type_name: "T".into(),
12800 durability: zerodds_qos::DurabilityKind::Volatile,
12801 reliability: ReliabilityQosPolicy::default(),
12802 ownership: zerodds_qos::OwnershipKind::Shared,
12803 ownership_strength: 0,
12804 liveliness: LivelinessQosPolicy::default(),
12805 deadline: zerodds_qos::DeadlineQosPolicy::default(),
12806 lifespan: zerodds_qos::LifespanQosPolicy::default(),
12807 partition: Vec::new(),
12808 user_data: Vec::new(),
12809 topic_data: Vec::new(),
12810 group_data: Vec::new(),
12811 type_information: None,
12812 data_representation: Vec::new(),
12813 security_info: None,
12814 service_instance_name: None,
12815 related_entity_guid: None,
12816 topic_aliases: None,
12817 type_identifier: zerodds_types::TypeIdentifier::None,
12818 unicast_locators: Vec::new(),
12819 multicast_locators: Vec::new(),
12820 },
12821 );
12822
12823 push_sedp_events_to_builtin_readers(&rt, &events);
12824
12825 let pub_reader = bs
12826 .lookup_datareader::<bt::PublicationBuiltinTopicData>("DCPSPublication")
12827 .unwrap();
12828 assert_eq!(pub_reader.take().unwrap().len(), 1);
12829 let topic_reader = bs
12830 .lookup_datareader::<bt::TopicBuiltinTopicData>("DCPSTopic")
12831 .unwrap();
12832 assert!(
12833 topic_reader.take().unwrap().is_empty(),
12834 "an ignored topic may block the synthetic DCPSTopic sample"
12835 );
12836 rt.shutdown();
12837 }
12838
12839 // -------- Security-Builtin-Endpoint-Wiring --------
12840
12841 /// Creates an SPDP beacon with configurable BuiltinEndpoint
12842 /// bits. Extension of [`make_remote_spdp_beacon`] with
12843 /// flag-Argument (Security-Bits 22..25).
12844 fn make_remote_spdp_beacon_with_flags(remote_prefix: GuidPrefix, endpoint_set: u32) -> Vec<u8> {
12845 use zerodds_discovery::spdp::SpdpBeacon;
12846 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12847 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12848 let data = ParticipantBuiltinTopicData {
12849 guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
12850 protocol_version: ProtocolVersion::V2_5,
12851 vendor_id: VendorId::ZERODDS,
12852 default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
12853 default_multicast_locator: None,
12854 metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
12855 metatraffic_multicast_locator: None,
12856 domain_id: Some(0),
12857 builtin_endpoint_set: endpoint_set,
12858 lease_duration: QosDuration::from_secs(100),
12859 user_data: alloc::vec::Vec::new(),
12860 properties: Default::default(),
12861 identity_token: None,
12862 permissions_token: None,
12863 identity_status_token: None,
12864 sig_algo_info: None,
12865 kx_algo_info: None,
12866 sym_cipher_algo_info: None,
12867 participant_security_info: None,
12868 };
12869 let mut beacon = SpdpBeacon::new(data);
12870 beacon.serialize().expect("serialize")
12871 }
12872
12873 fn dp_with_locators(
12874 prefix: GuidPrefix,
12875 metatraffic: Option<Locator>,
12876 default: Option<Locator>,
12877 ) -> zerodds_discovery::spdp::DiscoveredParticipant {
12878 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12879 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12880 zerodds_discovery::spdp::DiscoveredParticipant {
12881 sender_prefix: prefix,
12882 sender_vendor: VendorId::ZERODDS,
12883 data: ParticipantBuiltinTopicData {
12884 guid: Guid::new(prefix, EntityId::PARTICIPANT),
12885 protocol_version: ProtocolVersion::V2_5,
12886 vendor_id: VendorId::ZERODDS,
12887 default_unicast_locator: default,
12888 default_multicast_locator: None,
12889 metatraffic_unicast_locator: metatraffic,
12890 metatraffic_multicast_locator: None,
12891 domain_id: Some(0),
12892 builtin_endpoint_set: 0,
12893 lease_duration: QosDuration::from_secs(100),
12894 user_data: alloc::vec::Vec::new(),
12895 properties: Default::default(),
12896 identity_token: None,
12897 permissions_token: None,
12898 identity_status_token: None,
12899 sig_algo_info: None,
12900 kx_algo_info: None,
12901 sym_cipher_algo_info: None,
12902 participant_security_info: None,
12903 },
12904 }
12905 }
12906
12907 #[test]
12908 fn wlp_unicast_targets_prefers_metatraffic_then_default() {
12909 // M-2: WLP-Unicast-Fan-out waehlt pro Peer metatraffic_unicast (bevorzugt),
12910 // otherwise default_unicast; peers without a routable locator fall out.
12911 let meta = Locator::udp_v4([127, 0, 0, 1], 7501);
12912 let deflt = Locator::udp_v4([127, 0, 0, 2], 7500);
12913 let peers = alloc::vec![
12914 // (a) has metatraffic → metatraffic wins
12915 dp_with_locators(GuidPrefix::from_bytes([1; 12]), Some(meta), Some(deflt)),
12916 // (b) only default → default
12917 dp_with_locators(GuidPrefix::from_bytes([2; 12]), None, Some(deflt)),
12918 // (c) none at all → no target
12919 dp_with_locators(GuidPrefix::from_bytes([3; 12]), None, None),
12920 ];
12921 let targets = wlp_unicast_targets(&peers);
12922 assert_eq!(targets, alloc::vec![meta, deflt]);
12923 }
12924
12925 /// Like [`make_remote_spdp_beacon_with_flags`], but with a set
12926 /// `identity_token` (FU2 Gap 7d — triggers the auth handshake).
12927 #[cfg(feature = "security")]
12928 fn make_secure_beacon_with_identity_token(
12929 remote_prefix: GuidPrefix,
12930 endpoint_set: u32,
12931 identity_token: Vec<u8>,
12932 ) -> Vec<u8> {
12933 use zerodds_discovery::spdp::SpdpBeacon;
12934 use zerodds_rtps::participant_data::ParticipantBuiltinTopicData;
12935 use zerodds_rtps::wire_types::{ProtocolVersion, VendorId};
12936 let data = ParticipantBuiltinTopicData {
12937 guid: Guid::new(remote_prefix, EntityId::PARTICIPANT),
12938 protocol_version: ProtocolVersion::V2_5,
12939 vendor_id: VendorId::ZERODDS,
12940 default_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7500)),
12941 default_multicast_locator: None,
12942 metatraffic_unicast_locator: Some(Locator::udp_v4([127, 0, 0, 99], 7501)),
12943 metatraffic_multicast_locator: None,
12944 domain_id: Some(0),
12945 builtin_endpoint_set: endpoint_set,
12946 lease_duration: QosDuration::from_secs(100),
12947 user_data: alloc::vec::Vec::new(),
12948 properties: Default::default(),
12949 identity_token: Some(identity_token),
12950 permissions_token: None,
12951 identity_status_token: None,
12952 sig_algo_info: None,
12953 kx_algo_info: None,
12954 sym_cipher_algo_info: None,
12955 participant_security_info: None,
12956 };
12957 let mut beacon = SpdpBeacon::new(data);
12958 beacon.serialize().expect("serialize")
12959 }
12960
12961 /// Minimal auth plugin for the FU2 wiring tests (Gap 4/7).
12962 /// Crypto correctness is verified in the stack.rs driver test; here
12963 /// it is only about the runtime wiring path.
12964 #[cfg(feature = "security")]
12965 struct FakeAuth;
12966 #[cfg(feature = "security")]
12967 impl zerodds_security::authentication::AuthenticationPlugin for FakeAuth {
12968 fn validate_local_identity(
12969 &mut self,
12970 _: &zerodds_security::properties::PropertyList,
12971 _: [u8; 16],
12972 ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
12973 {
12974 Ok(zerodds_security::authentication::IdentityHandle(1))
12975 }
12976 fn validate_remote_identity(
12977 &mut self,
12978 _: zerodds_security::authentication::IdentityHandle,
12979 _: [u8; 16],
12980 _: &[u8],
12981 ) -> zerodds_security::error::SecurityResult<zerodds_security::authentication::IdentityHandle>
12982 {
12983 Ok(zerodds_security::authentication::IdentityHandle(2))
12984 }
12985 fn begin_handshake_request(
12986 &mut self,
12987 _: zerodds_security::authentication::IdentityHandle,
12988 _: zerodds_security::authentication::IdentityHandle,
12989 ) -> zerodds_security::error::SecurityResult<(
12990 zerodds_security::authentication::HandshakeHandle,
12991 zerodds_security::authentication::HandshakeStepOutcome,
12992 )> {
12993 Ok((
12994 zerodds_security::authentication::HandshakeHandle(1),
12995 zerodds_security::authentication::HandshakeStepOutcome::SendMessage {
12996 token: zerodds_security::token::DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")
12997 .to_cdr_le(),
12998 },
12999 ))
13000 }
13001 fn begin_handshake_reply(
13002 &mut self,
13003 _: zerodds_security::authentication::IdentityHandle,
13004 _: zerodds_security::authentication::IdentityHandle,
13005 _: &[u8],
13006 ) -> zerodds_security::error::SecurityResult<(
13007 zerodds_security::authentication::HandshakeHandle,
13008 zerodds_security::authentication::HandshakeStepOutcome,
13009 )> {
13010 Ok((
13011 zerodds_security::authentication::HandshakeHandle(2),
13012 zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer,
13013 ))
13014 }
13015 fn process_handshake(
13016 &mut self,
13017 _: zerodds_security::authentication::HandshakeHandle,
13018 _: &[u8],
13019 ) -> zerodds_security::error::SecurityResult<
13020 zerodds_security::authentication::HandshakeStepOutcome,
13021 > {
13022 Ok(zerodds_security::authentication::HandshakeStepOutcome::WaitingForPeer)
13023 }
13024 fn shared_secret(
13025 &self,
13026 _: zerodds_security::authentication::HandshakeHandle,
13027 ) -> zerodds_security::error::SecurityResult<
13028 zerodds_security::authentication::SharedSecretHandle,
13029 > {
13030 Err(zerodds_security::error::SecurityError::new(
13031 zerodds_security::error::SecurityErrorKind::BadArgument,
13032 "fake: handshake not complete",
13033 ))
13034 }
13035 fn plugin_class_id(&self) -> &str {
13036 "FAKE:Auth:1.0"
13037 }
13038 fn get_identity_token(
13039 &self,
13040 _: zerodds_security::authentication::IdentityHandle,
13041 ) -> zerodds_security::error::SecurityResult<Vec<u8>> {
13042 // Non-empty Token (Format irrelevant — FakeAuth.validate_remote_
13043 // identity accepts everything); only so the beacon-populate path
13044 // (Gap 7c) has something to announce.
13045 Ok(alloc::vec![0xAB, 0xCD, 0xEF, 0x01])
13046 }
13047 fn get_permissions_token(&self) -> Vec<u8> {
13048 // Non-empty PermissionsToken, so the beacon-populate path
13049 // (S4 point 1) has something to announce (format irrelevant).
13050 zerodds_security::token::DataHolder::new("DDS:Access:Permissions:1.0").to_cdr_le()
13051 }
13052 }
13053
13054 /// Consolidated test for the wiring. A single
13055 /// runtime walks all paths — snapshot API, idempotency of
13056 /// `enable_security_builtins`, SPDP hot path with security bits,
13057 /// without bits, plus the wire-demux hook. We bundle this into one
13058 /// test body, because each `DcpsRuntime::start` binds a multicast socket
13059 /// and parallel tests could brush against the OS resource caps.
13060 #[test]
13061 fn c34c_security_builtin_wiring_end_to_end() {
13062 use zerodds_discovery::security::SecurityBuiltinStack;
13063 use zerodds_security::generic_message::{
13064 MessageIdentity, ParticipantGenericMessage, class_id,
13065 };
13066 use zerodds_security::token::DataHolder;
13067
13068 let local_prefix = GuidPrefix::from_bytes([0x75; 12]);
13069 let rt = DcpsRuntime::start(75, local_prefix, RuntimeConfig::default()).expect("start");
13070
13071 // 1. Snapshot is None before enable
13072 assert!(rt.security_builtin_snapshot().is_none());
13073
13074 // 2. enable ist idempotent
13075 let h1 = rt.enable_security_builtins(VendorId::ZERODDS);
13076 let h2 = rt.enable_security_builtins(VendorId::ZERODDS);
13077 assert!(Arc::ptr_eq(&h1, &h2));
13078 assert!(rt.security_builtin_snapshot().is_some());
13079
13080 // 3. SPDP beacon with all security-builtin bits → the stack has
13081 // four proxies
13082 let remote_a = GuidPrefix::from_bytes([0x99; 12]);
13083 let flags_all = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13084 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
13085 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
13086 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
13087 handle_spdp_datagram(
13088 &rt,
13089 &make_remote_spdp_beacon_with_flags(remote_a, flags_all),
13090 );
13091 {
13092 let s = h1.lock().unwrap();
13093 assert_eq!(s.stateless_writer.reader_proxy_count(), 1);
13094 assert_eq!(s.stateless_reader.writer_proxy_count(), 1);
13095 assert_eq!(s.volatile_writer.reader_proxy_count(), 1);
13096 assert_eq!(s.volatile_reader.writer_proxy_count(), 1);
13097 }
13098
13099 // 4. SPDP beacon without security bits → the stack stays unchanged
13100 let remote_b = GuidPrefix::from_bytes([0x88; 12]);
13101 handle_spdp_datagram(
13102 &rt,
13103 &make_remote_spdp_beacon_with_flags(remote_b, endpoint_flag::ALL_STANDARD),
13104 );
13105 {
13106 let s = h1.lock().unwrap();
13107 assert_eq!(
13108 s.stateless_writer.reader_proxy_count(),
13109 1,
13110 "a peer without security bits must not touch existing proxies"
13111 );
13112 }
13113
13114 // 5. Wire-demux hook with a valid stateless DATA: remote-stack
13115 // mirror sends a message → the demux hook routes it through
13116 // the local reader without panic.
13117 let mut remote_stack = SecurityBuiltinStack::new(remote_a, VendorId::ZERODDS);
13118 let local_peer = make_remote_spdp_beacon_with_flags(local_prefix, flags_all);
13119 let parsed_local = zerodds_discovery::spdp::SpdpReader::new()
13120 .parse_datagram(&local_peer)
13121 .unwrap();
13122 remote_stack.handle_remote_endpoints(&parsed_local);
13123 let msg = ParticipantGenericMessage {
13124 message_identity: MessageIdentity {
13125 source_guid: [0xCD; 16],
13126 sequence_number: 1,
13127 },
13128 related_message_identity: MessageIdentity::default(),
13129 destination_participant_key: [0xEF; 16],
13130 destination_endpoint_key: [0; 16],
13131 source_endpoint_key: [0xFE; 16],
13132 message_class_id: class_id::AUTH_REQUEST.into(),
13133 message_data: alloc::vec![DataHolder::new("DDS:Auth:PKI-DH:1.2+AuthReq")],
13134 };
13135 let dgs = remote_stack.stateless_writer.write(&msg).unwrap();
13136 assert_eq!(dgs.len(), 1);
13137 dispatch_security_builtin_datagram(&rt, &dgs[0].bytes, Duration::from_secs(1));
13138
13139 // 6. The demux hook does not panic on garbage bytes
13140 dispatch_security_builtin_datagram(&rt, &[0u8; 32], Duration::from_secs(1));
13141
13142 rt.shutdown();
13143 }
13144
13145 /// FU2 Gap 4: `enable_security_builtins_with_auth` builds the stack with
13146 /// an active handshake driver — `begin_handshake_with` sends, as
13147 /// the initiator actually sends an AUTH_REQUEST (instead of a no-op like with
13148 /// the auth-less `enable_security_builtins`).
13149 #[cfg(feature = "security")]
13150 #[test]
13151 fn enable_security_builtins_with_auth_activates_handshake_driver() {
13152 use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
13153
13154 let local_prefix = GuidPrefix::from_bytes([0x40; 12]);
13155 let rt = DcpsRuntime::start(40, local_prefix, RuntimeConfig::default()).expect("start");
13156
13157 let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13158 let stack =
13159 rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
13160
13161 // Discover a peer with stateless bits (WITHOUT identity_token → the
13162 // discovery trigger starts no handshake yet) → proxies
13163 // are wired. The remote prefix is LARGER than local ([0x40]),
13164 // so that local is the initiator under the cyclone convention (smaller GUID
13165 // initiates) and actually sends.
13166 let remote = GuidPrefix::from_bytes([0x99; 12]);
13167 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13168 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13169 handle_spdp_datagram(&rt, &make_remote_spdp_beacon_with_flags(remote, flags));
13170
13171 let dgs = {
13172 let mut s = stack.lock().unwrap();
13173 let remote_guid = Guid::new(remote, EntityId::PARTICIPANT).to_bytes();
13174 s.begin_handshake_with(remote, remote_guid, b"fake-remote-cert-der")
13175 .expect("begin_handshake_with")
13176 };
13177 assert_eq!(
13178 dgs.len(),
13179 1,
13180 "auth driver active → the initiator sends exactly one AUTH_REQUEST"
13181 );
13182
13183 rt.shutdown();
13184 }
13185
13186 /// FU2 Gap 7c/d: `enable_security_builtins_with_auth` announces the
13187 /// local `identity_token` in the SPDP beacon (+ stateless/volatile bits),
13188 /// and an incoming peer beacon WITH an `identity_token` kicks off the
13189 /// Auth-Handshake an (Discovery-Trigger).
13190 #[cfg(feature = "security")]
13191 #[test]
13192 fn spdp_beacon_announces_identity_token_and_discovery_triggers_handshake() {
13193 use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
13194
13195 let local_prefix = GuidPrefix::from_bytes([0x41; 12]);
13196 let rt = DcpsRuntime::start(41, local_prefix, RuntimeConfig::default()).expect("start");
13197 let auth: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13198 let stack =
13199 rt.enable_security_builtins_with_auth(VendorId::ZERODDS, auth, IdentityHandle(1));
13200
13201 // Gap 7c: the beacon now announces identity_token + secure bits.
13202 let beacon_bytes = rt.spdp_beacon.lock().unwrap().serialize().unwrap();
13203 let parsed = zerodds_discovery::spdp::SpdpReader::new()
13204 .parse_datagram(&beacon_bytes)
13205 .unwrap();
13206 assert!(
13207 parsed.data.identity_token.is_some(),
13208 "the beacon must announce PID_IDENTITY_TOKEN"
13209 );
13210 // Cross-vendor: secure vendors validate a remote only when
13211 // SPDP carries **both** tokens. Without PID_PERMISSIONS_TOKEN they treat
13212 // cyclone treats us as non-secure and never starts validate_remote_identity.
13213 assert!(
13214 parsed.data.permissions_token.is_some(),
13215 "the beacon must announce PID_PERMISSIONS_TOKEN (cross-vendor mandatory)"
13216 );
13217 assert_ne!(
13218 parsed.data.builtin_endpoint_set & endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER,
13219 0,
13220 "the beacon must announce the stateless-auth bit"
13221 );
13222
13223 // Gap 7d: peer beacon WITH identity_token + stateless bits → the
13224 // discovery path kicks off begin_handshake_with.
13225 let remote = GuidPrefix::from_bytes([0x99; 12]);
13226 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13227 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13228 let peer_beacon =
13229 make_secure_beacon_with_identity_token(remote, flags, alloc::vec![0x11, 0x22, 0x33]);
13230 handle_spdp_datagram(&rt, &peer_beacon);
13231
13232 // Proof that the discovery trigger fired: the peer is now
13233 // registered in the stack's handshake state. (The earlier length
13234 // probe via a repeated begin_handshake_with no longer applies since the resend path
13235 // resends as the initiator on a repeated call.)
13236 let started = {
13237 let s = stack.lock().unwrap();
13238 s.handshake_peer_count()
13239 };
13240 assert_eq!(
13241 started, 1,
13242 "the discovery trigger must have started the handshake (peer registered)"
13243 );
13244
13245 rt.shutdown();
13246 }
13247
13248 /// FU2 S3: two secure runtimes in the same process MUST find each other via
13249 /// in-process participant discovery and kick off the auth handshake
13250 /// — WITHOUT a single multicast beacon. That was exactly missing:
13251 /// `inproc_inject_publication`/`_subscription` inject only SEDP, the
13252 /// SPDP participant discovery (identity_token + `begin_handshake_with`)
13253 /// ran exclusively over the flaky multicast path.
13254 #[cfg(feature = "security")]
13255 #[test]
13256 fn inproc_participant_discovery_triggers_handshake_without_multicast() {
13257 use zerodds_security::authentication::{AuthenticationPlugin, IdentityHandle};
13258
13259 let a_prefix = GuidPrefix::from_bytes([0x4A; 12]);
13260 let b_prefix = GuidPrefix::from_bytes([0x4B; 12]);
13261 let rt_a = DcpsRuntime::start(47, a_prefix, RuntimeConfig::default()).expect("start a");
13262 let rt_b = DcpsRuntime::start(47, b_prefix, RuntimeConfig::default()).expect("start b");
13263 let auth_a: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13264 let auth_b: Arc<Mutex<dyn AuthenticationPlugin>> = Arc::new(Mutex::new(FakeAuth));
13265 let stack_a =
13266 rt_a.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_a, IdentityHandle(1));
13267 let stack_b =
13268 rt_b.enable_security_builtins_with_auth(VendorId::ZERODDS, auth_b, IdentityHandle(1));
13269
13270 // KEIN handle_spdp_datagram / Multicast — rein in-process.
13271 let a_peers = stack_a.lock().unwrap().handshake_peer_count();
13272 let b_peers = stack_b.lock().unwrap().handshake_peer_count();
13273 assert!(
13274 a_peers >= 1,
13275 "A must have discovered B in-process + started the handshake (got {a_peers})"
13276 );
13277 assert!(
13278 b_peers >= 1,
13279 "B must have discovered A in-process + started the handshake (got {b_peers})"
13280 );
13281
13282 rt_a.shutdown();
13283 rt_b.shutdown();
13284 }
13285
13286 /// Mints a shared CA + two leaf identities (PEM) for the
13287 /// FU2-Handshake-e2e-Test.
13288 #[cfg(feature = "security")]
13289 #[allow(clippy::type_complexity)]
13290 fn mint_handshake_identities() -> ((Vec<u8>, Vec<u8>), (Vec<u8>, Vec<u8>)) {
13291 use rcgen::{CertificateParams, KeyPair};
13292 let mut ca_params =
13293 CertificateParams::new(alloc::vec![alloc::string::String::from("FU2 CA")]).unwrap();
13294 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
13295 let ca_key = KeyPair::generate().unwrap();
13296 let ca_cert = ca_params.self_signed(&ca_key).unwrap();
13297 let ca_pem = ca_cert.pem().into_bytes();
13298 let mint = |name: &str| -> (Vec<u8>, Vec<u8>) {
13299 let mut p =
13300 CertificateParams::new(alloc::vec![alloc::string::String::from(name)]).unwrap();
13301 p.is_ca = rcgen::IsCa::NoCa;
13302 let k = KeyPair::generate().unwrap();
13303 let c = p.signed_by(&k, &ca_cert, &ca_key).unwrap();
13304 (c.pem().into_bytes(), k.serialize_pem().into_bytes())
13305 };
13306 let alice = {
13307 let (cert, key) = mint("alice");
13308 (cert, key)
13309 };
13310 let bob = {
13311 let (cert, key) = mint("bob");
13312 (cert, key)
13313 };
13314 // attach ca_pem to both, so the caller has the trust anchor.
13315 (
13316 ([alice.0, b"\n".to_vec(), ca_pem.clone()].concat(), alice.1),
13317 ([bob.0, b"\n".to_vec(), ca_pem].concat(), bob.1),
13318 )
13319 }
13320
13321 /// FU2 Gap 5 (e2e): a runtime replier (A) and an in-test initiator
13322 /// stack (B) complete a real PKI 3-round handshake via the dispatch path
13323 /// and BOTH derive the same SharedSecret.
13324 /// Verifies the dispatch wiring (`on_stateless_message` →
13325 /// reply/final → completion) in the real runtime context.
13326 #[cfg(feature = "security")]
13327 #[test]
13328 fn handshake_completes_through_runtime_dispatch_e2e() {
13329 use zerodds_discovery::security::SecurityBuiltinStack;
13330 use zerodds_security::authentication::AuthenticationPlugin;
13331 use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
13332
13333 // cert_pem here contains Leaf || CA (mint_handshake_identities),
13334 // identity_ca_pem = the same bundle (CA is included).
13335 let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
13336
13337 // A = Runtime (Replier, HOEHERER Prefix). B = in-test Stack
13338 // (initiator, LOWER prefix) — cyclone convention: smaller
13339 // GUID initiiert.
13340 let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
13341 let b_prefix = GuidPrefix::from_bytes([0x10; 12]);
13342 let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
13343 let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
13344
13345 // --- A: runtime with a real PKI plugin ---
13346 let a_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13347 let a_local = a_pki
13348 .lock()
13349 .unwrap()
13350 .validate_with_config(
13351 IdentityConfig {
13352 identity_cert_pem: a_cert.clone(),
13353 identity_ca_pem: a_cert.clone(),
13354 identity_key_pem: Some(a_key),
13355 },
13356 a_guid,
13357 )
13358 .unwrap();
13359 let a_token = a_pki.lock().unwrap().get_identity_token(a_local).unwrap();
13360 let rt = DcpsRuntime::start(42, a_prefix, RuntimeConfig::default()).expect("start");
13361 let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = a_pki.clone();
13362 let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
13363
13364 // --- B: in-test initiator stack with a real PKI plugin ---
13365 let b_pki = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13366 let b_local = b_pki
13367 .lock()
13368 .unwrap()
13369 .validate_with_config(
13370 IdentityConfig {
13371 identity_cert_pem: b_cert.clone(),
13372 identity_ca_pem: b_cert.clone(),
13373 identity_key_pem: Some(b_key),
13374 },
13375 b_guid,
13376 )
13377 .unwrap();
13378 let b_token = b_pki.lock().unwrap().get_identity_token(b_local).unwrap();
13379 let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = b_pki.clone();
13380 let mut b_stack =
13381 SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
13382
13383 let stateless = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13384 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13385
13386 // B discovers A (wired proxies) — via the parsed A beacon.
13387 let a_beacon = make_secure_beacon_with_identity_token(a_prefix, stateless, a_token.clone());
13388 let a_parsed = zerodds_discovery::spdp::SpdpReader::new()
13389 .parse_datagram(&a_beacon)
13390 .unwrap();
13391 b_stack.handle_remote_endpoints(&a_parsed);
13392
13393 // A discovers B → the discovery trigger creates A's peer state (A is
13394 // the replier, sends nothing).
13395 let b_beacon = make_secure_beacon_with_identity_token(b_prefix, stateless, b_token);
13396 handle_spdp_datagram(&rt, &b_beacon);
13397
13398 // B (initiator) starts → AUTH_REQUEST.
13399 let req = b_stack
13400 .begin_handshake_with(a_prefix, a_guid, &a_token)
13401 .unwrap();
13402 assert_eq!(req.len(), 1, "B sends AUTH_REQUEST");
13403
13404 // Pump: REQUEST → A.dispatch → REPLY.
13405 let reply = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
13406 assert_eq!(reply.len(), 1, "A (replier) answers with AUTH reply");
13407
13408 // REPLY → B verarbeitet → FINAL (+ B erreicht Complete).
13409 let b_msgs = b_stack
13410 .stateless_reader
13411 .handle_datagram(&reply[0].bytes)
13412 .unwrap();
13413 assert_eq!(b_msgs.len(), 1);
13414 let (final_dgs, _b_complete) = b_stack.on_stateless_message(a_prefix, &b_msgs[0]).unwrap();
13415 assert_eq!(final_dgs.len(), 1, "B sends AUTH-Final");
13416
13417 // FINAL → A.dispatch → A erreicht Complete.
13418 let _ =
13419 dispatch_security_builtin_datagram(&rt, &final_dgs[0].bytes, Duration::from_secs(1));
13420
13421 // Both sides must now have derived the same SharedSecret.
13422 let a_secret = {
13423 let s = a_stack.lock().unwrap();
13424 s.peer_secret(b_prefix)
13425 .expect("A must have authenticated B")
13426 };
13427 let b_secret = b_stack
13428 .peer_secret(a_prefix)
13429 .expect("B must have authenticated A");
13430 let a_bytes = a_pki
13431 .lock()
13432 .unwrap()
13433 .secret_bytes(a_secret)
13434 .unwrap()
13435 .to_vec();
13436 let b_bytes = b_pki
13437 .lock()
13438 .unwrap()
13439 .secret_bytes(b_secret)
13440 .unwrap()
13441 .to_vec();
13442 assert_eq!(a_bytes.len(), 32);
13443 assert_eq!(
13444 a_bytes, b_bytes,
13445 "runtime dispatch + in-test stack derive the same secret"
13446 );
13447
13448 rt.shutdown();
13449 }
13450
13451 /// FU2 S1.5 (e2e): after the auth handshake the runtime dispatch
13452 /// (A, replier) and a reference peer (B, stack+gate, initiator) over
13453 /// the Kx-protected VolatileSecure channel automatically exchange their data
13454 /// crypto tokens — afterwards secured user DATA round-trips in BOTH
13455 /// directions. **The secured-DATA proof via the runtime dispatch.**
13456 #[cfg(feature = "security")]
13457 #[test]
13458 #[serial_test::serial(dcps_security_e2e)]
13459 fn secured_data_round_trips_through_runtime_dispatch_e2e() {
13460 use zerodds_discovery::security::SecurityBuiltinStack;
13461 use zerodds_security::authentication::{AuthenticationPlugin, SharedSecretProvider};
13462 use zerodds_security::generic_message::{
13463 MessageIdentity, ParticipantGenericMessage, class_id,
13464 };
13465 use zerodds_security::token::DataHolder;
13466 use zerodds_security_crypto::{AesGcmCryptoPlugin, Suite};
13467 use zerodds_security_pki::{IdentityConfig, PkiAuthenticationPlugin};
13468 use zerodds_security_runtime::{ProtectionLevel, SharedSecurityGate};
13469
13470 // Couples the pki plugin (behind a mutex) as the SharedSecretProvider to
13471 // the crypto plugin — like SecurityProfile in the FFI (Gap 1).
13472 struct PkiProvider(Arc<Mutex<PkiAuthenticationPlugin>>);
13473 impl SharedSecretProvider for PkiProvider {
13474 fn get_shared_secret(
13475 &self,
13476 h: zerodds_security::authentication::SharedSecretHandle,
13477 ) -> Option<Vec<u8>> {
13478 self.0.lock().ok()?.get_shared_secret(h)
13479 }
13480 }
13481 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>"#;
13482 let gov = || zerodds_security_permissions::parse_governance_xml(GOV).unwrap();
13483 let gate_with = |pki: &Arc<Mutex<PkiAuthenticationPlugin>>| {
13484 SharedSecurityGate::new(
13485 0,
13486 gov(),
13487 Box::new(AesGcmCryptoPlugin::with_secret_provider(
13488 Suite::Aes128Gcm,
13489 Arc::new(PkiProvider(pki.clone())) as Arc<dyn SharedSecretProvider>,
13490 )),
13491 )
13492 };
13493 let fake_rtps = |prefix: GuidPrefix, body: &[u8]| -> Vec<u8> {
13494 let mut m = Vec::new();
13495 m.extend_from_slice(b"RTPS\x02\x05\x01\x02");
13496 m.extend_from_slice(&prefix.to_bytes());
13497 m.extend_from_slice(body);
13498 m
13499 };
13500
13501 let ((a_cert, a_key), (b_cert, b_key)) = mint_handshake_identities();
13502 let a_prefix = GuidPrefix::from_bytes([0x20; 12]);
13503 let b_prefix = GuidPrefix::from_bytes([0x10; 12]); // B < A → B initiator (cyclone convention)
13504 let a_guid = Guid::new(a_prefix, EntityId::PARTICIPANT).to_bytes();
13505 let b_guid = Guid::new(b_prefix, EntityId::PARTICIPANT).to_bytes();
13506 let a_key_pk = a_prefix.to_bytes();
13507 let b_key_pk = b_prefix.to_bytes();
13508
13509 // --- A: runtime with auth + gate (sharing pki_a) ---
13510 let pki_a = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13511 let a_local = pki_a
13512 .lock()
13513 .unwrap()
13514 .validate_with_config(
13515 IdentityConfig {
13516 identity_cert_pem: a_cert.clone(),
13517 identity_ca_pem: a_cert.clone(),
13518 identity_key_pem: Some(a_key),
13519 },
13520 a_guid,
13521 )
13522 .unwrap();
13523 let a_token = pki_a.lock().unwrap().get_identity_token(a_local).unwrap();
13524 let gate_a = Arc::new(gate_with(&pki_a));
13525 let rt = DcpsRuntime::start(
13526 43,
13527 a_prefix,
13528 RuntimeConfig {
13529 security: Some(gate_a.clone()),
13530 ..RuntimeConfig::default()
13531 },
13532 )
13533 .expect("start");
13534 let a_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_a.clone();
13535 let a_stack = rt.enable_security_builtins_with_auth(VendorId::ZERODDS, a_auth, a_local);
13536
13537 // --- B: in-test Stack + Gate (sharing pki_b), Initiator ---
13538 let pki_b = Arc::new(Mutex::new(PkiAuthenticationPlugin::new()));
13539 let b_local = pki_b
13540 .lock()
13541 .unwrap()
13542 .validate_with_config(
13543 IdentityConfig {
13544 identity_cert_pem: b_cert.clone(),
13545 identity_ca_pem: b_cert.clone(),
13546 identity_key_pem: Some(b_key),
13547 },
13548 b_guid,
13549 )
13550 .unwrap();
13551 let b_token = pki_b.lock().unwrap().get_identity_token(b_local).unwrap();
13552 let gate_b = gate_with(&pki_b);
13553 let b_auth: Arc<Mutex<dyn AuthenticationPlugin>> = pki_b.clone();
13554 let mut stack_b =
13555 SecurityBuiltinStack::with_auth(b_prefix, VendorId::ZERODDS, b_auth, b_local, b_guid);
13556
13557 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13558 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER
13559 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_WRITER
13560 | endpoint_flag::PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER;
13561 let a_beacon = make_secure_beacon_with_identity_token(a_prefix, flags, a_token.clone());
13562 stack_b.handle_remote_endpoints(
13563 &zerodds_discovery::spdp::SpdpReader::new()
13564 .parse_datagram(&a_beacon)
13565 .unwrap(),
13566 );
13567 // Wire A's stack deterministically (no handle_spdp_datagram —
13568 // a running runtime + trigger otherwise produces non-deterministic
13569 // proxy wirings via parallel/loopback beacons). A is the replier:
13570 // begin_handshake_with only sets up the peer state.
13571 let b_beacon = make_secure_beacon_with_identity_token(b_prefix, flags, b_token.clone());
13572 let b_parsed = zerodds_discovery::spdp::SpdpReader::new()
13573 .parse_datagram(&b_beacon)
13574 .unwrap();
13575 {
13576 let mut s = a_stack.lock().unwrap();
13577 s.handle_remote_endpoints(&b_parsed);
13578 s.begin_handshake_with(b_prefix, b_guid, &b_token).unwrap();
13579 }
13580
13581 // --- Stateless-Handshake pumpen (B initiiert) ---
13582 // A is the replier and derives the secret already at begin_handshake_
13583 // reply → A's response to the request contains BOTH: the
13584 // AUTH reply (stateless) AND A's Kx-encrypted crypto token
13585 // (volatile, automatically via the dispatch).
13586 let decode_route = |dgs: &[zerodds_rtps::message_builder::OutboundDatagram]| {
13587 let mut stateless = Vec::new();
13588 let mut volatile = Vec::new();
13589 for dg in dgs {
13590 let parsed = zerodds_rtps::datagram::decode_datagram(&dg.bytes).unwrap();
13591 let is_vol = parsed.submessages.iter().any(|sub| {
13592 // Klartext-Pfad (unprotected): DATA an den VolatileSecure-Reader.
13593 matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Data(d)
13594 if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER)
13595 // Cross-vendor path (protected): the volatile crypto-token DATA
13596 // is SEC_*-protected (protect_volatile_outbound) -> the inner
13597 // DATA is encrypted and recognizable only by the prepended SEC_PREFIX
13598 // submessage (id 0x31). Stateless AUTH stays plaintext.
13599 || matches!(sub, zerodds_rtps::datagram::ParsedSubmessage::Unknown { id: 0x31, .. })
13600 });
13601 if is_vol {
13602 volatile.push(dg.bytes.clone());
13603 } else {
13604 stateless.push(dg.bytes.clone());
13605 }
13606 }
13607 (stateless, volatile)
13608 };
13609
13610 let req = stack_b
13611 .begin_handshake_with(a_prefix, a_guid, &a_token)
13612 .unwrap();
13613 let a_resp = dispatch_security_builtin_datagram(&rt, &req[0].bytes, Duration::from_secs(1));
13614 let (a_stateless, a_volatile) = decode_route(&a_resp);
13615 assert!(
13616 !a_volatile.is_empty(),
13617 "A dispatch must send A's crypto token"
13618 );
13619
13620 // B verarbeitet A's AUTH-Reply → Final + B completes.
13621 let mut b_remote_id = None;
13622 let mut b_secret = None;
13623 let mut b_final = Vec::new();
13624 for sl in &a_stateless {
13625 for m in stack_b.stateless_reader.handle_datagram(sl).unwrap() {
13626 let (out, comp) = stack_b.on_stateless_message(a_prefix, &m).unwrap();
13627 b_final.extend(out);
13628 if let Some((id, sec)) = comp {
13629 b_remote_id = Some(id);
13630 b_secret = Some(sec);
13631 }
13632 }
13633 }
13634 let b_remote_id = b_remote_id.expect("B remote identity");
13635 let b_secret = b_secret.expect("B completes");
13636
13637 // B registers A's Kx, installs A's crypto token (from a_volatile).
13638 gate_b
13639 .register_remote_by_guid_from_secret(a_key_pk, b_remote_id, b_secret)
13640 .unwrap();
13641 // A's volatile crypto token is cross-vendor SEC_*-protected
13642 // (protect_volatile_outbound). B must decrypt the SEC_PREFIX/BODY/POSTFIX sequence
13643 // with A's Kx key to the inner DATA submessage before the
13644 // volatile_reader can process it — mirrors unprotect_volatile_
13645 // datagram im Live-Dispatch.
13646 let unprotect_vol_b = |bytes: &[u8]| -> Option<Vec<u8>> {
13647 let subs = walk_submessages(bytes);
13648 let prefix_pos = subs.iter().position(|(id, _, _)| *id == SMID_SEC_PREFIX)?;
13649 let postfix_idx = subs[prefix_pos..]
13650 .iter()
13651 .position(|(id, _, _)| *id == SMID_SEC_POSTFIX)
13652 .map(|i| prefix_pos + i)?;
13653 let (_, p_start, _) = subs[prefix_pos];
13654 let (_, q_start, q_total) = subs[postfix_idx];
13655 let data_submsg = gate_b
13656 .decode_kx_datawriter_from(&a_key_pk, &bytes[p_start..q_start + q_total])
13657 .ok()?;
13658 let mut out = Vec::with_capacity(bytes.len());
13659 out.extend_from_slice(&bytes[..20]);
13660 for (i, &(_, start, total)) in subs.iter().enumerate() {
13661 if i < prefix_pos || i > postfix_idx {
13662 out.extend_from_slice(&bytes[start..start + total]);
13663 } else if i == prefix_pos {
13664 out.extend_from_slice(&data_submsg);
13665 }
13666 }
13667 Some(out)
13668 };
13669 let mut b_installed = 0;
13670 for vol in &a_volatile {
13671 let vol_plain = unprotect_vol_b(vol).unwrap_or_else(|| vol.clone());
13672 let parsed = zerodds_rtps::datagram::decode_datagram(&vol_plain).unwrap();
13673 let vol_src = parsed.header.guid_prefix;
13674 for sub in parsed.submessages {
13675 if let zerodds_rtps::datagram::ParsedSubmessage::Data(d) = sub {
13676 if d.reader_id == EntityId::BUILTIN_PARTICIPANT_VOLATILE_MESSAGE_SECURE_READER {
13677 for m in stack_b.volatile_reader.handle_data(vol_src, &d).unwrap() {
13678 if m.message_class_id == class_id::PARTICIPANT_CRYPTO_TOKENS {
13679 // plaintext keymat (confidentiality was provided by the SEC_*
13680 // protection of the volatile DATA, decrypted above) —
13681 // install directly, no transform_kx_inbound.
13682 let token = m.message_data[0]
13683 .binary_property(CRYPTO_TOKEN_PROP)
13684 .unwrap();
13685 gate_b
13686 .set_remote_data_token_by_guid(&a_key_pk, token)
13687 .unwrap();
13688 b_installed += 1;
13689 }
13690 }
13691 }
13692 }
13693 }
13694 }
13695 assert!(b_installed >= 1, "B must install A's crypto token");
13696
13697 // B builds + sends its crypto token — plaintext keymat in the
13698 // ParticipantGenericMessage (cross-vendor: confidentiality via SEC_*
13699 // protection of the transporting volatile DATA, not via token-internal
13700 // Kx encryption).
13701 let b_data_token = gate_b.local_token().unwrap();
13702 let b_crypto_msg = ParticipantGenericMessage {
13703 message_identity: MessageIdentity {
13704 source_guid: b_guid,
13705 sequence_number: 1,
13706 },
13707 related_message_identity: MessageIdentity::default(),
13708 destination_participant_key: a_guid,
13709 destination_endpoint_key: [0; 16],
13710 source_endpoint_key: [0; 16],
13711 message_class_id: class_id::PARTICIPANT_CRYPTO_TOKENS.into(),
13712 message_data: alloc::vec![
13713 DataHolder::new("DDS:Crypto:AES_GCM_GMAC")
13714 .with_binary_property(CRYPTO_TOKEN_PROP, b_data_token)
13715 ],
13716 };
13717 let b_volatile = stack_b.volatile_writer.write(&b_crypto_msg).unwrap();
13718 // SEC_* submessage protection with A's Kx key (mirrors protect_volatile_
13719 // datagram in the live path): B encrypts the DATA submessage, A's
13720 // dispatch decrypts it via unprotect_volatile_datagram.
13721 let protect_vol_b = |bytes: &[u8]| -> Vec<u8> {
13722 let subs = walk_submessages(bytes);
13723 if !subs.iter().any(|(id, _, _)| *id == SMID_DATA) {
13724 return bytes.to_vec();
13725 }
13726 let mut out = Vec::with_capacity(bytes.len() + 64);
13727 out.extend_from_slice(&bytes[..20]);
13728 for (id, start, total) in subs {
13729 let submsg = &bytes[start..start + total];
13730 if id == SMID_DATA {
13731 out.extend_from_slice(
13732 &gate_b.encode_kx_datawriter_for(&a_key_pk, submsg).unwrap(),
13733 );
13734 } else {
13735 out.extend_from_slice(submsg);
13736 }
13737 }
13738 out
13739 };
13740 let b_vol_protected = protect_vol_b(&b_volatile[0].bytes);
13741
13742 // B's Final + B's Crypto-Token an A's Dispatch: A installiert B's
13743 // Data token (automatically via install_crypto_token).
13744 for f in &b_final {
13745 dispatch_security_builtin_datagram(&rt, &f.bytes, Duration::from_secs(1));
13746 }
13747 dispatch_security_builtin_datagram(&rt, &b_vol_protected, Duration::from_secs(1));
13748
13749 // --- Secured DATA in both directions ---
13750 let msg_ab = fake_rtps(a_prefix, b"[A->B secured payload]");
13751 let wire_ab = gate_a
13752 .transform_outbound_for(&b_key_pk, &msg_ab, ProtectionLevel::Encrypt)
13753 .unwrap();
13754 assert_eq!(
13755 gate_b.transform_inbound_from(&a_key_pk, &wire_ab).unwrap(),
13756 msg_ab,
13757 "A->B secured DATA must round-trip"
13758 );
13759 let msg_ba = fake_rtps(b_prefix, b"[B->A secured payload]");
13760 let wire_ba = gate_b
13761 .transform_outbound_for(&a_key_pk, &msg_ba, ProtectionLevel::Encrypt)
13762 .unwrap();
13763 assert_eq!(
13764 gate_a.transform_inbound_from(&b_key_pk, &wire_ba).unwrap(),
13765 msg_ba,
13766 "B->A secured DATA must round-trip (A's dispatch installed B's token)"
13767 );
13768
13769 rt.shutdown();
13770 }
13771
13772 #[test]
13773 fn c34c_enable_security_builtins_replays_known_peers() {
13774 // Order reversed: SPDP discovery first, plugin-
13775 // activation afterward. enable_security_builtins must catch up on already-
13776 // known peers. Plus: demux without a plugin (before enable)
13777 // is a no-op + does not panic.
13778 let rt = DcpsRuntime::start(
13779 76,
13780 GuidPrefix::from_bytes([0x76; 12]),
13781 RuntimeConfig::default(),
13782 )
13783 .expect("start");
13784
13785 // Demux without a plugin: silent no-op
13786 dispatch_security_builtin_datagram(&rt, &[0u8; 16], Duration::from_secs(1));
13787
13788 let remote = GuidPrefix::from_bytes([0x77; 12]);
13789 let flags = endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_WRITER
13790 | endpoint_flag::PARTICIPANT_STATELESS_MESSAGE_READER;
13791 let dg = make_remote_spdp_beacon_with_flags(remote, flags);
13792 handle_spdp_datagram(&rt, &dg);
13793
13794 let stack = rt.enable_security_builtins(VendorId::ZERODDS);
13795 {
13796 let s = stack.lock().unwrap();
13797 assert_eq!(
13798 s.stateless_writer.reader_proxy_count(),
13799 1,
13800 "late plugin activation must catch up on known peers"
13801 );
13802 }
13803
13804 rt.shutdown();
13805 }
13806
13807 /// #29 regression: the earlier per-peer once-guard blocked late-matched
13808 /// user-endpoint tokens. `pending_endpoint_tokens` must, with already-sent
13809 /// builtin tokens, let through EXACTLY the new user token — not treat the whole
13810 /// peer as "done".
13811 #[cfg(feature = "security")]
13812 #[test]
13813 fn pending_endpoint_tokens_keeps_late_user_token_after_builtins_sent() {
13814 use zerodds_security::generic_message::ParticipantGenericMessage;
13815 // An early-sent builtin token (secure-SEDP) ...
13816 let builtin = ParticipantGenericMessage {
13817 source_endpoint_key: [0xff; 16],
13818 destination_endpoint_key: [0xfe; 16],
13819 ..Default::default()
13820 };
13821 // ... and a late-matched user-endpoint token.
13822 let user = ParticipantGenericMessage {
13823 source_endpoint_key: [0x03; 16],
13824 destination_endpoint_key: [0x04; 16],
13825 ..Default::default()
13826 };
13827 let mut sent = alloc::collections::BTreeSet::new();
13828 sent.insert(endpoint_token_key(&builtin));
13829
13830 let pending = pending_endpoint_tokens(vec![builtin.clone(), user.clone()], &sent);
13831
13832 assert_eq!(pending.len(), 1, "only the new user token may be pending");
13833 assert_eq!(
13834 pending[0].source_endpoint_key, user.source_endpoint_key,
13835 "the let-through token must be the user-endpoint token"
13836 );
13837 // Idempotency: after sending, nothing is pending anymore.
13838 let mut sent2 = sent.clone();
13839 sent2.insert(endpoint_token_key(&user));
13840 assert!(
13841 pending_endpoint_tokens(vec![builtin, user], &sent2).is_empty(),
13842 "already-sent tokens must not become pending again"
13843 );
13844 }
13845}