Skip to main content

zerodds_rtps/
publication_data.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! PublicationBuiltinTopicData (DDSI-RTPS 2.5 §8.5.4.2, §9.6.2.2.3).
4//!
5//! Content of the SEDP publications DATA submessage that a participant
6//! sends to make a local DataWriter known to remote participants.
7//! Serialized as a PL_CDR_LE-encoded ParameterList in the
8//! `serialized_payload` of a DATA submessage.
9//!
10//! topic_name + type_name + GUIDs +
11//! minimal QoS fields (durability, reliability). No deadline,
12//! liveliness, lifespan, ownership, partition etc. — those are read and
13//! stored in the `extra` vec, but not typed.
14//!
15//! **QoS enums local here** — once WP 1.5 brings full QoS matching,
16//! DurabilityKind/ReliabilityKind move to `zerodds-qos`.
17
18extern crate alloc;
19use alloc::string::String;
20use alloc::vec::Vec;
21
22use crate::endpoint_security_info::EndpointSecurityInfo;
23use crate::error::WireError;
24use crate::parameter_list::{Parameter, ParameterList, pid};
25use crate::participant_data::{Duration, ENCAPSULATION_PL_CDR_LE};
26use crate::wire_types::{Guid, Locator};
27
28/// Durability-QoS Kind.
29///
30/// Canonical in [`zerodds_qos::DurabilityKind`]; RTPS re-exports for
31/// backward compatibility.
32pub use zerodds_qos::DurabilityKind;
33
34/// Reliability-QoS Kind.
35///
36/// Canonical in [`zerodds_qos::ReliabilityKind`]; RTPS re-exportiert.
37pub use zerodds_qos::ReliabilityKind;
38
39/// Reliability QoS value: kind + max_blocking_time.
40///
41/// Canonical in [`zerodds_qos::ReliabilityQosPolicy`]; RTPS re-exportiert
42/// under the historical alias `ReliabilityQos`.
43pub use zerodds_qos::ReliabilityQosPolicy as ReliabilityQos;
44
45/// `DataRepresentationId` — XTypes 1.3 §7.6.3.1.1 + RTPS 2.5 PID 0x0073.
46///
47/// Per spec: 16-bit signed integer; values 0..2 are normatively
48/// defined. Per RTI/Cyclone/FastDDS convention, further values are
49/// reserved as vendor-specific.
50pub mod data_representation {
51    /// XCDR1 (legacy CDR Plain-CDR + PL_CDR mutable). Default when the
52    /// PID is not present (spec §7.6.3.1.2).
53    pub const XCDR: i16 = 0;
54    /// XML (rare, for CFP profiles). Not in our default list.
55    pub const XML: i16 = 1;
56    /// XCDR2 (PLAIN_CDR2 + DELIMITED_CDR2 + PL_CDR2 mutable).
57    /// ZeroDDS' native encap (`0x0007`/`0x0009`/`0x000B`).
58    pub const XCDR2: i16 = 2;
59
60    /// ZeroDDS default announce list for writers and readers.
61    ///
62    /// **XCDR2-only.** The codegen (`idl-rust`/`idl-cpp`) emits **real
63    /// XCDR2** since the version-aware alignment cap (XTypes 1.3
64    /// §7.4.1.1.1, 64-bit to 4) — the encap (`user_payload_encap`)
65    /// derives from `offer_first`. Since the body is fixed XCDR2-aligned,
66    /// `offer_first` MUST be XCDR2; XCDR1 must NOT be in the list,
67    /// otherwise an XCDR1-only reader matches wrongly (tolerant mode) and
68    /// reads the XCDR2 body with the wrong alignment.
69    ///
70    /// Match behavior:
71    /// - Reader [XCDR2] / [XCDR1, XCDR2] (Cyclone/FastDDS/modern RTI): ✓
72    /// - Reader [XCDR1] (legacy-strict, e.g. old RTI shapes): ✗ — such
73    ///   peers need an XCDR1 codegen (separate feature), not just an
74    ///   offer change.
75    ///
76    /// User override: globally via `RuntimeConfig::data_representation_offer`
77    /// or env `ZERODDS_DATA_REPR_OFFER`.
78    pub const DEFAULT_OFFER: [i16; 1] = [XCDR2];
79
80    /// `DataRepMatchMode` — determines how writer and reader compare
81    /// DataRep lists.
82    ///
83    /// * **Strict** (XTypes 1.3 §7.6.3.1.2 normative): the writer's FIRST
84    ///   element must be in the reader's list. Exactly like RTI Connext.
85    /// * **Tolerant** (industry norm, Cyclone + FastDDS): match when the
86    ///   lists overlap (any-overlap), wire format = first-overlap.
87    ///
88    /// Default in ZeroDDS: `Tolerant` — maximizes interop, because our
89    /// own readers also match RTI writers even when the first element is
90    /// not 100% identical.
91    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92    pub enum DataRepMatchMode {
93        /// Strict-spec match: Writer.first ∈ Reader.list.
94        Strict,
95        /// Tolerant match: any element in Writer.list ∈ Reader.list.
96        /// Industry default.
97        #[default]
98        Tolerant,
99    }
100
101    /// Determines the negotiated wire format between writer and reader.
102    ///
103    /// Returns `Some(id)` with the DataRepresentationId to emit on the
104    /// wire. `None` means: no overlap — no match.
105    ///
106    /// `writer_offered` can contain multiple values (e.g.
107    /// `[XCDR2, XCDR1]`); `reader_accepted` likewise.
108    /// Both lists can be empty — the spec default is `[XCDR1]` in that
109    /// case (spec §7.6.3.1.2).
110    #[must_use]
111    pub fn negotiate(
112        writer_offered: &[i16],
113        reader_accepted: &[i16],
114        mode: DataRepMatchMode,
115    ) -> Option<i16> {
116        // Spec defaults for empty lists.
117        let w_default = [XCDR];
118        let r_default = [XCDR];
119        let w: &[i16] = if writer_offered.is_empty() {
120            &w_default
121        } else {
122            writer_offered
123        };
124        let r: &[i16] = if reader_accepted.is_empty() {
125            &r_default
126        } else {
127            reader_accepted
128        };
129
130        match mode {
131            DataRepMatchMode::Strict => {
132                // §7.6.3.1.2: the writer's first element must be in the reader's list.
133                let first = w.first().copied()?;
134                if r.contains(&first) {
135                    Some(first)
136                } else {
137                    None
138                }
139            }
140            DataRepMatchMode::Tolerant => {
141                // Industry: any overlap. We prefer the writer's
142                // preference order (first-match wins), but see the FULL
143                // writer list, not just first.
144                w.iter().copied().find(|id| r.contains(id))
145            }
146        }
147    }
148
149    /// Encap header (4 byte) for @final structs under the given
150    /// DataRep. For @appendable/@mutable a different encap code is
151    /// needed — see `encap_for_extensibility`.
152    ///
153    /// Return format: `[byte0, byte1, byte2, byte3]` where byte0 is
154    /// always `0x00` and byte1 the repr id per RTPS 2.5 §10.5.
155    #[must_use]
156    pub fn encap_for_final_le(id: i16) -> [u8; 4] {
157        match id {
158            XCDR2 => [0x00, 0x07, 0x00, 0x00], // PLAIN_CDR2_LE
159            _ => [0x00, 0x01, 0x00, 0x00],     // CDR_LE (XCDR1 default)
160        }
161    }
162}
163
164/// Discovered Publication / lokaler DataWriter — Subset.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct PublicationBuiltinTopicData {
167    /// Endpoint-GUID (= Writer-GUID).
168    pub key: Guid,
169    /// GUID of the participant the writer belongs to.
170    pub participant_key: Guid,
171    /// Topic-Name (DDS-Topic, z.B. "ChatterTopic").
172    pub topic_name: String,
173    /// IDL-Type-Name (z.B. "std_msgs::String").
174    pub type_name: String,
175    /// Durability-QoS.
176    pub durability: DurabilityKind,
177    /// Reliability-QoS.
178    pub reliability: ReliabilityQos,
179    /// Ownership-QoS (Spec §2.2.3.23). Default Shared.
180    pub ownership: zerodds_qos::OwnershipKind,
181    /// Ownership strength (spec §2.2.3.24). Only relevant when
182    /// `ownership == Exclusive`; default 0.
183    pub ownership_strength: i32,
184    /// Liveliness QoS (spec §2.2.3.11).
185    pub liveliness: zerodds_qos::LivelinessQosPolicy,
186    /// Deadline QoS (spec §2.2.3.7).
187    pub deadline: zerodds_qos::DeadlineQosPolicy,
188    /// Lifespan QoS (spec §2.2.3.16) — writer-only.
189    pub lifespan: zerodds_qos::LifespanQosPolicy,
190    /// Partition QoS (spec §2.2.3.13). Empty list = "default partition" ("").
191    pub partition: Vec<String>,
192    /// UserData QoS (spec §2.2.3.1) — opaque sequence<octet>,
193    /// discovery-propagated. Empty vec = not set.
194    pub user_data: Vec<u8>,
195    /// TopicData QoS (spec §2.2.3.3) — opaque sequence<octet>,
196    /// propagated from the topic via pub discovery.
197    pub topic_data: Vec<u8>,
198    /// GroupData QoS (spec §2.2.3.2) — opaque sequence<octet>,
199    /// propagated from the publisher via pub discovery.
200    pub group_data: Vec<u8>,
201    /// Type information (TypeIdentifier hashes + dependencies, XTypes
202    /// §7.6.3.2.2). Opaque bytes: the structure lives in `zerodds-types`,
203    /// but we transport the serialized blob to avoid circular crate
204    /// dependencies.
205    pub type_information: Option<Vec<u8>>,
206    /// Accepted data representations (0=XCDR1, 1=XML, 2=XCDR2, ...).
207    /// Spec: XTypes 1.3 §7.6.3.1.1 / RTPS 2.5 PID 0x0073.
208    /// The default list when empty is `[XCDR1]` per XTypes §7.6.3.1.2 —
209    /// we always emit the PID explicitly so strict vendors like
210    /// RTI 7.7.0 can SEDP-match.
211    pub data_representation: Vec<i16>,
212    /// Endpoint security info (PID 0x1004, DDS-Security 1.1 §7.4.1.5).
213    /// `None` for legacy peers without a security PID. WP 4H-c matches on
214    /// it: writer/reader pairs with incompatible protection levels are
215    /// rejected.
216    pub security_info: Option<EndpointSecurityInfo>,
217    /// PID_SERVICE_INSTANCE_NAME (DDS-RPC 1.0 §7.8.2) — logical
218    /// service-instance name of an RPC endpoint. `None` for ordinary
219    /// pub/sub topics.
220    pub service_instance_name: Option<String>,
221    /// PID_RELATED_ENTITY_GUID (DDS-RPC 1.0 §7.8.2) — GUID of the
222    /// counterpart endpoint in an RPC endpoint pair. For a request
223    /// writer it points to the reply reader of the same requester; for a
224    /// reply writer to the request reader of the same replier.
225    pub related_entity_guid: Option<Guid>,
226    /// PID_TOPIC_ALIASES (DDS-RPC 1.0 §7.8.2) — alternative topic names
227    /// for routing/compat layers. Order is significant.
228    pub topic_aliases: Option<Vec<String>>,
229    /// PID_ZERODDS_TYPE_ID (vendor PID 0x8002) — XTypes 1.3 §7.3.4.2
230    /// TypeIdentifier of the writer type for XTypes-aware reader matching
231    /// (XTypes §7.6.3.7 + DDS 1.4 §2.2.3 TypeConsistencyEnforcement).
232    pub type_identifier: zerodds_types::TypeIdentifier,
233    /// Endpoint unicast locators (DDSI-RTPS 2.5 §8.5.3.3:
234    /// `DiscoveredWriterData.writerProxy.unicastLocatorList`). Where
235    /// peers send user data to *this* writer endpoint. Empty = the peer
236    /// falls back to the participant `DEFAULT_UNICAST_LOCATOR` from
237    /// SPDP (§8.5.5). OpenDDS stores the real user locator exclusively
238    /// here and sends only the placeholder 127.0.0.1:12345 as the SPDP
239    /// default.
240    pub unicast_locators: Vec<Locator>,
241    /// Endpoint multicast locators (DDSI-RTPS 2.5 §8.5.3.3).
242    pub multicast_locators: Vec<Locator>,
243}
244
245/// Emits a separate parameter per locator — an RTPS locator list is the
246/// repeated PID (spec §8.3.5.2 ParameterList).
247pub fn encode_locator_params(params: &mut ParameterList, pid_id: u16, locators: &[Locator]) {
248    for loc in locators {
249        params.push(Parameter::new(pid_id, loc.to_bytes_le().to_vec()));
250    }
251}
252
253/// Collects all locators announced under `pid_id` (a repeated PID =
254/// list). BE locators are skipped — consistent with
255/// `participant_data::decode_locator` (BE locator not implemented).
256#[must_use]
257pub fn collect_locator_params(
258    pl: &ParameterList,
259    pid_id: u16,
260    little_endian: bool,
261) -> Vec<Locator> {
262    if !little_endian {
263        return Vec::new();
264    }
265    pl.parameters
266        .iter()
267        .filter(|p| p.id == pid_id && p.value.len() == Locator::WIRE_SIZE)
268        .filter_map(|p| {
269            let mut b = [0u8; 24];
270            b.copy_from_slice(&p.value);
271            Locator::from_bytes_le(b).ok()
272        })
273        .collect()
274}
275
276impl PublicationBuiltinTopicData {
277    /// Encodes to PL_CDR_LE bytes (with a 4-byte encapsulation header).
278    /// The output is directly usable as the `serialized_payload` of a DATA
279    /// submessage.
280    ///
281    /// # Errors
282    /// `ValueOutOfRange` if a string is longer than u32::MAX.
283    pub fn to_pl_cdr_le(&self) -> Result<Vec<u8>, WireError> {
284        let mut params = ParameterList::new();
285
286        // PARTICIPANT_GUID: 16 Byte
287        params.push(Parameter::new(
288            pid::PARTICIPANT_GUID,
289            self.participant_key.to_bytes().to_vec(),
290        ));
291
292        // ENDPOINT_GUID: 16 Byte
293        params.push(Parameter::new(
294            pid::ENDPOINT_GUID,
295            self.key.to_bytes().to_vec(),
296        ));
297
298        // TOPIC_NAME: CDR-String (4 byte len + UTF-8 + null)
299        params.push(Parameter::new(
300            pid::TOPIC_NAME,
301            encode_cdr_string_le(&self.topic_name)?,
302        ));
303
304        // TYPE_NAME: CDR-String
305        params.push(Parameter::new(
306            pid::TYPE_NAME,
307            encode_cdr_string_le(&self.type_name)?,
308        ));
309
310        // DURABILITY: 4 Byte u32
311        params.push(Parameter::new(
312            pid::DURABILITY,
313            (self.durability as u32).to_le_bytes().to_vec(),
314        ));
315
316        // RELIABILITY: 4 Byte kind + 8 Byte max_blocking_time
317        let mut rel = Vec::with_capacity(12);
318        rel.extend_from_slice(&(self.reliability.kind as u32).to_le_bytes());
319        rel.extend_from_slice(&self.reliability.max_blocking_time.to_bytes_le());
320        params.push(Parameter::new(pid::RELIABILITY, rel));
321
322        // OWNERSHIP: 4 Byte u32 kind
323        params.push(Parameter::new(
324            pid::OWNERSHIP,
325            encode_u32_le(self.ownership as u32).to_vec(),
326        ));
327
328        // OWNERSHIP_STRENGTH: 4 byte int32 (only meaningful for
329        // Exclusive, but we always send it — the reader ignores it for Shared).
330        params.push(Parameter::new(
331            pid::OWNERSHIP_STRENGTH,
332            encode_u32_le(self.ownership_strength as u32).to_vec(),
333        ));
334
335        // LIVELINESS: 4 Byte kind + 8 Byte lease_duration
336        params.push(Parameter::new(
337            pid::LIVELINESS,
338            encode_liveliness_le(self.liveliness),
339        ));
340
341        // DEADLINE: 8 Byte Duration_t
342        params.push(Parameter::new(
343            pid::DEADLINE,
344            encode_duration_le(self.deadline.period).to_vec(),
345        ));
346
347        // LIFESPAN: 8 Byte Duration_t
348        params.push(Parameter::new(
349            pid::LIFESPAN,
350            encode_duration_le(self.lifespan.duration).to_vec(),
351        ));
352
353        // PARTITION: only if non-empty — an empty list = default (= "").
354        if !self.partition.is_empty() {
355            params.push(Parameter::new(
356                pid::PARTITION,
357                encode_partition_le(&self.partition)?,
358            ));
359        }
360
361        // USER_DATA / TOPIC_DATA / GROUP_DATA: opaque sequence<octet>.
362        // Wire = 4 byte u32 length + N byte data. Only if set
363        // (an empty vec = default, we leave it out of the ParameterList).
364        if !self.user_data.is_empty() {
365            params.push(Parameter::new(
366                pid::USER_DATA,
367                encode_octet_seq_le(&self.user_data)?,
368            ));
369        }
370        if !self.topic_data.is_empty() {
371            params.push(Parameter::new(
372                pid::TOPIC_DATA,
373                encode_octet_seq_le(&self.topic_data)?,
374            ));
375        }
376        if !self.group_data.is_empty() {
377            params.push(Parameter::new(
378                pid::GROUP_DATA,
379                encode_octet_seq_le(&self.group_data)?,
380            ));
381        }
382
383        // TYPE_INFORMATION: serialized TypeInformation blob
384        // (optional, XTypes §7.6.3.2.2).
385        if let Some(ti) = &self.type_information {
386            params.push(Parameter::new(pid::TYPE_INFORMATION, ti.clone()));
387        }
388
389        // ENDPOINT_SECURITY_INFO: 2x u32 masks (§7.4.1.5). Only if set,
390        // otherwise legacy behavior (Cyclone/Fast-DDS without security
391        // leave the PID out).
392        if let Some(info) = self.security_info {
393            params.push(Parameter::new(
394                pid::ENDPOINT_SECURITY_INFO,
395                info.to_bytes(true).to_vec(),
396            ));
397        }
398
399        // ----------------------------------------------------------------
400        // DDS-RPC 1.0 discovery PIDs (§7.8.2) — only if set.
401        // ----------------------------------------------------------------
402        if let Some(name) = &self.service_instance_name {
403            params.push(Parameter::new(
404                pid::SERVICE_INSTANCE_NAME,
405                encode_cdr_string_le(name)?,
406            ));
407        }
408        if let Some(guid) = self.related_entity_guid {
409            params.push(Parameter::new(
410                pid::RELATED_ENTITY_GUID,
411                guid.to_bytes().to_vec(),
412            ));
413        }
414        if let Some(aliases) = &self.topic_aliases {
415            params.push(Parameter::new(
416                pid::TOPIC_ALIASES,
417                encode_partition_le(aliases)?,
418            ));
419        }
420
421        // PID_ZERODDS_TYPE_ID (F-TYPES-3 Wire-up).
422        if self.type_identifier != zerodds_types::TypeIdentifier::None {
423            let mut w = zerodds_cdr::BufferWriter::new(zerodds_cdr::Endianness::Little);
424            self.type_identifier
425                .encode_into(&mut w)
426                .map_err(|_| WireError::ValueOutOfRange {
427                    message: "type_identifier encoding failed",
428                })?;
429            params.push(Parameter::new(pid::ZERODDS_TYPE_ID, w.into_bytes()));
430        }
431
432        // DATA_REPRESENTATION: sequence<int16> — u32 length + 2*N bytes.
433        if !self.data_representation.is_empty() {
434            let mut dr = Vec::with_capacity(4 + 2 * self.data_representation.len());
435            let len = u32::try_from(self.data_representation.len()).map_err(|_| {
436                WireError::ValueOutOfRange {
437                    message: "data_representation length exceeds u32::MAX",
438                }
439            })?;
440            dr.extend_from_slice(&len.to_le_bytes());
441            for rep in &self.data_representation {
442                dr.extend_from_slice(&rep.to_le_bytes());
443            }
444            params.push(Parameter::new(pid::DATA_REPRESENTATION, dr));
445        }
446
447        // Endpoint locators (§8.5.3.3) — one parameter per locator.
448        encode_locator_params(&mut params, pid::UNICAST_LOCATOR, &self.unicast_locators);
449        encode_locator_params(
450            &mut params,
451            pid::MULTICAST_LOCATOR,
452            &self.multicast_locators,
453        );
454
455        let mut out = Vec::with_capacity(params.parameters.len() * 24 + 16);
456        out.extend_from_slice(&ENCAPSULATION_PL_CDR_LE);
457        out.extend_from_slice(&[0, 0]); // options
458        out.extend_from_slice(&params.to_bytes(true));
459        Ok(out)
460    }
461
462    /// Decodes from PL_CDR_LE bytes (with an encapsulation header).
463    ///
464    /// # Errors
465    /// `UnexpectedEof` on too-short bytes,
466    /// `UnsupportedEncapsulation` on unknown encoding,
467    /// `ValueOutOfRange` if mandatory PIDs are missing or values have the
468    /// wrong length.
469    pub fn from_pl_cdr_le(bytes: &[u8]) -> Result<Self, WireError> {
470        if bytes.len() < 4 {
471            return Err(WireError::UnexpectedEof {
472                needed: 4,
473                offset: 0,
474            });
475        }
476        let little_endian = match &bytes[..2] {
477            b if b == ENCAPSULATION_PL_CDR_LE => true,
478            [0x00, 0x02] => false,
479            other => {
480                return Err(WireError::UnsupportedEncapsulation {
481                    kind: [other[0], other[1]],
482                });
483            }
484        };
485        let pl = ParameterList::from_bytes(&bytes[4..], little_endian)?;
486
487        let key = pl
488            .find(pid::ENDPOINT_GUID)
489            .and_then(guid_from_param)
490            .ok_or(WireError::ValueOutOfRange {
491                message: "ENDPOINT_GUID missing or wrong length",
492            })?;
493
494        // PARTICIPANT_GUID is technically optional (can be derived from
495        // the ENDPOINT_GUID prefix), but we require it when present.
496        let participant_key = pl
497            .find(pid::PARTICIPANT_GUID)
498            .and_then(guid_from_param)
499            .unwrap_or_else(|| {
500                // Fallback: participant = ENDPOINT_GUID.prefix + PARTICIPANT EntityId
501                Guid::new(key.prefix, crate::wire_types::EntityId::PARTICIPANT)
502            });
503
504        let topic_name = pl
505            .find(pid::TOPIC_NAME)
506            .map(|p| decode_cdr_string(&p.value, little_endian))
507            .transpose()?
508            .ok_or(WireError::ValueOutOfRange {
509                message: "TOPIC_NAME missing",
510            })?;
511
512        let type_name = pl
513            .find(pid::TYPE_NAME)
514            .map(|p| decode_cdr_string(&p.value, little_endian))
515            .transpose()?
516            .ok_or(WireError::ValueOutOfRange {
517                message: "TYPE_NAME missing",
518            })?;
519
520        let durability = pl
521            .find(pid::DURABILITY)
522            .and_then(|p| {
523                if p.value.len() >= 4 {
524                    let mut b = [0u8; 4];
525                    b.copy_from_slice(&p.value[..4]);
526                    Some(DurabilityKind::from_u32(if little_endian {
527                        u32::from_le_bytes(b)
528                    } else {
529                        u32::from_be_bytes(b)
530                    }))
531                } else {
532                    None
533                }
534            })
535            .unwrap_or_default();
536
537        let reliability = pl
538            .find(pid::RELIABILITY)
539            .and_then(|p| {
540                if p.value.len() >= 12 {
541                    let mut k = [0u8; 4];
542                    k.copy_from_slice(&p.value[..4]);
543                    let kind = ReliabilityKind::from_u32(if little_endian {
544                        u32::from_le_bytes(k)
545                    } else {
546                        u32::from_be_bytes(k)
547                    });
548                    let mut d = [0u8; 8];
549                    d.copy_from_slice(&p.value[4..12]);
550                    let max_blocking_time = if little_endian {
551                        Duration::from_bytes_le(d)
552                    } else {
553                        // BE decoding: interpret seconds+fraction as BE
554                        let mut s = [0u8; 4];
555                        s.copy_from_slice(&d[..4]);
556                        let mut f = [0u8; 4];
557                        f.copy_from_slice(&d[4..]);
558                        Duration {
559                            seconds: i32::from_be_bytes(s),
560                            fraction: u32::from_be_bytes(f),
561                        }
562                    };
563                    Some(ReliabilityQos {
564                        kind,
565                        max_blocking_time,
566                    })
567                } else {
568                    None
569                }
570            })
571            .unwrap_or_default();
572
573        let ownership = pl
574            .find(pid::OWNERSHIP)
575            .and_then(|p| decode_u32(&p.value, little_endian))
576            .map(zerodds_qos::OwnershipKind::from_u32)
577            .unwrap_or_default();
578
579        let ownership_strength = pl
580            .find(pid::OWNERSHIP_STRENGTH)
581            .and_then(|p| decode_i32(&p.value, little_endian))
582            .unwrap_or(0);
583
584        let liveliness = pl
585            .find(pid::LIVELINESS)
586            .and_then(|p| decode_liveliness(&p.value, little_endian))
587            .unwrap_or_default();
588
589        let deadline = pl
590            .find(pid::DEADLINE)
591            .and_then(|p| decode_duration(&p.value, little_endian))
592            .map(|period| zerodds_qos::DeadlineQosPolicy { period })
593            .unwrap_or_default();
594
595        let lifespan = pl
596            .find(pid::LIFESPAN)
597            .and_then(|p| decode_duration(&p.value, little_endian))
598            .map(|duration| zerodds_qos::LifespanQosPolicy { duration })
599            .unwrap_or_default();
600
601        let partition = pl
602            .find(pid::PARTITION)
603            .and_then(|p| decode_partition(&p.value, little_endian))
604            .unwrap_or_default();
605
606        let user_data = pl
607            .find(pid::USER_DATA)
608            .and_then(|p| decode_octet_seq(&p.value, little_endian))
609            .unwrap_or_default();
610        let topic_data = pl
611            .find(pid::TOPIC_DATA)
612            .and_then(|p| decode_octet_seq(&p.value, little_endian))
613            .unwrap_or_default();
614        let group_data = pl
615            .find(pid::GROUP_DATA)
616            .and_then(|p| decode_octet_seq(&p.value, little_endian))
617            .unwrap_or_default();
618
619        let type_information = pl.find(pid::TYPE_INFORMATION).map(|p| p.value.clone());
620
621        let security_info = pl
622            .find(pid::ENDPOINT_SECURITY_INFO)
623            .and_then(|p| EndpointSecurityInfo::from_bytes(&p.value, little_endian).ok());
624
625        let service_instance_name = pl
626            .find(pid::SERVICE_INSTANCE_NAME)
627            .map(|p| decode_cdr_string(&p.value, little_endian))
628            .transpose()
629            .ok()
630            .flatten();
631        let related_entity_guid = pl.find(pid::RELATED_ENTITY_GUID).and_then(guid_from_param);
632        let topic_aliases = pl
633            .find(pid::TOPIC_ALIASES)
634            .and_then(|p| decode_partition(&p.value, little_endian));
635
636        let type_identifier = pl
637            .find(pid::ZERODDS_TYPE_ID)
638            .and_then(|p| {
639                let mut r =
640                    zerodds_cdr::BufferReader::new(&p.value, zerodds_cdr::Endianness::Little);
641                zerodds_types::TypeIdentifier::decode_from(&mut r).ok()
642            })
643            .unwrap_or_default();
644
645        let data_representation = pl
646            .find(pid::DATA_REPRESENTATION)
647            .map(|p| {
648                let v = &p.value;
649                if v.len() < 4 {
650                    return Vec::new();
651                }
652                let mut n_bytes = [0u8; 4];
653                n_bytes.copy_from_slice(&v[..4]);
654                let n = if little_endian {
655                    u32::from_le_bytes(n_bytes)
656                } else {
657                    u32::from_be_bytes(n_bytes)
658                } as usize;
659                // DoS cap: Vec::with_capacity(n) could reserve ~4 GB at
660                // n=u32::MAX/2. Cap to actually readable elements:
661                // (v.len()-4)/2 i16 elements.
662                let cap = n.min(v.len().saturating_sub(4) / 2);
663                let mut reps = Vec::with_capacity(cap);
664                for i in 0..n {
665                    let off = 4 + i * 2;
666                    if off + 2 > v.len() {
667                        break;
668                    }
669                    let mut b = [0u8; 2];
670                    b.copy_from_slice(&v[off..off + 2]);
671                    reps.push(if little_endian {
672                        i16::from_le_bytes(b)
673                    } else {
674                        i16::from_be_bytes(b)
675                    });
676                }
677                reps
678            })
679            .unwrap_or_default();
680
681        Ok(Self {
682            key,
683            participant_key,
684            topic_name,
685            type_name,
686            durability,
687            reliability,
688            ownership,
689            ownership_strength,
690            liveliness,
691            deadline,
692            lifespan,
693            partition,
694            user_data,
695            topic_data,
696            group_data,
697            type_information,
698            data_representation,
699            security_info,
700            service_instance_name,
701            related_entity_guid,
702            topic_aliases,
703            type_identifier,
704            unicast_locators: collect_locator_params(&pl, pid::UNICAST_LOCATOR, little_endian),
705            multicast_locators: collect_locator_params(&pl, pid::MULTICAST_LOCATOR, little_endian),
706        })
707    }
708}
709
710// ============================================================================
711// Helpers
712// ============================================================================
713
714/// ADR-0006 / zerodds-flatdata-1.0 §3.1: injects PID_SHM_LOCATOR
715/// (vendor PID 0x8001) into an already PL-CDR-LE-encoded
716/// `PublicationBuiltinTopicData` byte sequence. The vendor PID carries
717/// NO MUST_UNDERSTAND bit — foreign vendors ignore it safely, ZeroDDS
718/// readers on the same host attach to SHM.
719///
720/// Side-map pattern: the field does not move into the wire struct
721/// (otherwise 21+ construction sites cross-workspace), but lives as a
722/// `BTreeMap<EntityId, Vec<u8>>` in `DcpsRuntime` and is injected via
723/// this helper at the wire-encode end.
724///
725/// The content of `locator_bytes` is the already-packed SHM locator
726/// structure (see zerodds-flatdata-1.0 §3.1.2:
727/// `u32 hostname_hash` plus `u32 uid` plus `u32 slot_count` plus
728/// `u32 slot_size` plus CDR-string `segment_path`). The caller
729/// serializes that before the call.
730///
731/// # Errors
732/// `ValueOutOfRange` if `bytes` has no sentinel trailer
733/// (`0x01 0x00 0x00 0x00`) at the end or is too short.
734pub fn inject_pid_shm_locator(bytes: &mut Vec<u8>, locator_bytes: &[u8]) -> Result<(), WireError> {
735    use crate::parameter_list::pid;
736    if bytes.len() < 4 {
737        return Err(WireError::ValueOutOfRange {
738            message: "inject_pid_shm_locator: bytes too short",
739        });
740    }
741    let sentinel_pos = bytes.len() - 4;
742    // Sentinel tag = 0x01 0x00 (PID_SENTINEL) + 0x00 0x00 (length).
743    if bytes[sentinel_pos..] != [0x01, 0x00, 0x00, 0x00] {
744        return Err(WireError::ValueOutOfRange {
745            message: "inject_pid_shm_locator: missing PID_SENTINEL trailer",
746        });
747    }
748    // Padded to a 4-byte boundary for ParameterList conformance.
749    let padded_len = (locator_bytes.len() + 3) & !3;
750    if padded_len > u16::MAX as usize {
751        return Err(WireError::ValueOutOfRange {
752            message: "inject_pid_shm_locator: locator > u16::MAX",
753        });
754    }
755    let mut inject = Vec::with_capacity(4 + padded_len + 4);
756    inject.extend_from_slice(&pid::SHM_LOCATOR.to_le_bytes());
757    inject.extend_from_slice(&(padded_len as u16).to_le_bytes());
758    inject.extend_from_slice(locator_bytes);
759    // Zero-pad to a 4-byte boundary.
760    inject.resize(inject.len() + (padded_len - locator_bytes.len()), 0);
761    // Append the (removed) sentinel trailer.
762    inject.extend_from_slice(&bytes[sentinel_pos..]);
763    bytes.truncate(sentinel_pos);
764    bytes.extend_from_slice(&inject);
765    Ok(())
766}
767
768pub(crate) fn guid_from_param(p: &Parameter) -> Option<Guid> {
769    if p.value.len() == 16 {
770        let mut g = [0u8; 16];
771        g.copy_from_slice(&p.value);
772        Some(Guid::from_bytes(g))
773    } else {
774        None
775    }
776}
777
778/// CDR string as value bytes (incl. a 4-byte length prefix + null
779/// terminator, plus possible padding to a 4-byte boundary). LE only —
780/// ParameterList values are written in the endianness of the
781/// submessage, which we have fixed to LE.
782/// Encodes an 8-byte LE Duration_t. No trailing padding (out-aligned).
783pub(crate) fn encode_duration_le(d: Duration) -> [u8; 8] {
784    let mut out = [0u8; 8];
785    out[..4].copy_from_slice(&d.seconds.to_le_bytes());
786    out[4..].copy_from_slice(&d.fraction.to_le_bytes());
787    out
788}
789
790pub(crate) fn decode_duration(value: &[u8], little_endian: bool) -> Option<Duration> {
791    if value.len() < 8 {
792        return None;
793    }
794    let mut s = [0u8; 4];
795    s.copy_from_slice(&value[..4]);
796    let mut f = [0u8; 4];
797    f.copy_from_slice(&value[4..8]);
798    if little_endian {
799        Some(Duration {
800            seconds: i32::from_le_bytes(s),
801            fraction: u32::from_le_bytes(f),
802        })
803    } else {
804        Some(Duration {
805            seconds: i32::from_be_bytes(s),
806            fraction: u32::from_be_bytes(f),
807        })
808    }
809}
810
811/// Encode u32 LE.
812pub(crate) fn encode_u32_le(v: u32) -> [u8; 4] {
813    v.to_le_bytes()
814}
815
816pub(crate) fn decode_u32(value: &[u8], little_endian: bool) -> Option<u32> {
817    if value.len() < 4 {
818        return None;
819    }
820    let mut b = [0u8; 4];
821    b.copy_from_slice(&value[..4]);
822    if little_endian {
823        Some(u32::from_le_bytes(b))
824    } else {
825        Some(u32::from_be_bytes(b))
826    }
827}
828
829pub(crate) fn decode_i32(value: &[u8], little_endian: bool) -> Option<i32> {
830    decode_u32(value, little_endian).map(|u| u as i32)
831}
832
833/// Encode LivelinessQos: 4 byte kind + 8 byte lease_duration = 12 byte.
834pub(crate) fn encode_liveliness_le(l: zerodds_qos::LivelinessQosPolicy) -> Vec<u8> {
835    let mut out = Vec::with_capacity(12);
836    out.extend_from_slice(&(l.kind as u32).to_le_bytes());
837    out.extend_from_slice(&encode_duration_le(l.lease_duration));
838    out
839}
840
841pub(crate) fn decode_liveliness(
842    value: &[u8],
843    little_endian: bool,
844) -> Option<zerodds_qos::LivelinessQosPolicy> {
845    if value.len() < 12 {
846        return None;
847    }
848    let kind_u = decode_u32(&value[..4], little_endian)?;
849    let lease = decode_duration(&value[4..12], little_endian)?;
850    Some(zerodds_qos::LivelinessQosPolicy {
851        kind: zerodds_qos::LivelinessKind::from_u32(kind_u),
852        lease_duration: lease,
853    })
854}
855
856/// Partition = sequence<string>. CDR layout: u32 count + N × CDR string
857/// (each CDR string with its own alignment padding).
858/// Encodes an opaque `sequence<octet>` as `u32 length + N byte data`,
859/// padded to a 4-byte boundary. DDS QoS UserData/TopicData/GroupData.
860pub fn encode_octet_seq_le(data: &[u8]) -> Result<Vec<u8>, WireError> {
861    let len = u32::try_from(data.len()).map_err(|_| WireError::ValueOutOfRange {
862        message: "octet sequence length exceeds u32::MAX",
863    })?;
864    let mut out = Vec::with_capacity(4 + data.len() + 3);
865    out.extend_from_slice(&len.to_le_bytes());
866    out.extend_from_slice(data);
867    while out.len() % 4 != 0 {
868        out.push(0);
869    }
870    Ok(out)
871}
872
873/// Decodes an opaque `sequence<octet>` from the PID value.
874pub fn decode_octet_seq(value: &[u8], little_endian: bool) -> Option<Vec<u8>> {
875    let n = decode_u32(value, little_endian)? as usize;
876    if 4 + n > value.len() {
877        return None;
878    }
879    Some(value[4..4 + n].to_vec())
880}
881
882pub(crate) fn encode_partition_le(partitions: &[String]) -> Result<Vec<u8>, WireError> {
883    let mut out = Vec::new();
884    let len = u32::try_from(partitions.len()).map_err(|_| WireError::ValueOutOfRange {
885        message: "partition count exceeds u32::MAX",
886    })?;
887    out.extend_from_slice(&len.to_le_bytes());
888    for p in partitions {
889        // Each nested string starts on a 4-byte boundary relative to the
890        // start of the PARTITION value. The outer count is exactly 4
891        // bytes, so the first string is already aligned. After each
892        // string we pad to 4 again.
893        out.extend_from_slice(&encode_cdr_string_le(p)?);
894    }
895    Ok(out)
896}
897
898pub(crate) fn decode_partition(value: &[u8], little_endian: bool) -> Option<Vec<String>> {
899    let n = decode_u32(value, little_endian)? as usize;
900    // DoS cap: at most as many strings as would fit in the buffer with
901    // a minimal 1-byte string + 4-byte length + 4-byte pad = 12 bytes
902    // per entry. Otherwise a 4 GB reservation at n=u32::MAX.
903    let cap = n.min(value.len().saturating_sub(4) / 5);
904    let mut out = Vec::with_capacity(cap);
905    let mut pos = 4;
906    for _ in 0..n {
907        if pos + 4 > value.len() {
908            return None;
909        }
910        let mut lb = [0u8; 4];
911        lb.copy_from_slice(&value[pos..pos + 4]);
912        let slen = if little_endian {
913            u32::from_le_bytes(lb)
914        } else {
915            u32::from_be_bytes(lb)
916        } as usize;
917        let next_raw_end = pos + 4 + slen;
918        if next_raw_end > value.len() {
919            return None;
920        }
921        let s =
922            decode_cdr_string(&value[pos..next_raw_end.min(value.len())], little_endian).ok()?;
923        out.push(s);
924        // Pad to a 4-byte boundary.
925        let padded_end = (next_raw_end + 3) & !3;
926        pos = padded_end;
927    }
928    Some(out)
929}
930
931pub(crate) fn encode_cdr_string_le(s: &str) -> Result<Vec<u8>, WireError> {
932    let bytes = s.as_bytes();
933    let len =
934        u32::try_from(bytes.len().saturating_add(1)).map_err(|_| WireError::ValueOutOfRange {
935            message: "CDR string length exceeds u32::MAX",
936        })?;
937    let mut out = Vec::with_capacity(4 + bytes.len() + 4);
938    out.extend_from_slice(&len.to_le_bytes());
939    out.extend_from_slice(bytes);
940    out.push(0); // null-terminator
941    // Padding to a 4-byte boundary (required per ParameterList value)
942    while out.len() % 4 != 0 {
943        out.push(0);
944    }
945    Ok(out)
946}
947
948/// Decode a CDR string from value bytes. Ignores trailing padding.
949pub(crate) fn decode_cdr_string(value: &[u8], little_endian: bool) -> Result<String, WireError> {
950    if value.len() < 4 {
951        return Err(WireError::UnexpectedEof {
952            needed: 4,
953            offset: 0,
954        });
955    }
956    let mut lb = [0u8; 4];
957    lb.copy_from_slice(&value[..4]);
958    let len = if little_endian {
959        u32::from_le_bytes(lb)
960    } else {
961        u32::from_be_bytes(lb)
962    } as usize;
963    if len == 0 {
964        return Err(WireError::ValueOutOfRange {
965            message: "CDR string length 0 (missing null terminator)",
966        });
967    }
968    if value.len() < 4 + len {
969        return Err(WireError::UnexpectedEof {
970            needed: 4 + len,
971            offset: 0,
972        });
973    }
974    let raw = &value[4..4 + len];
975    if raw[len - 1] != 0 {
976        return Err(WireError::ValueOutOfRange {
977            message: "CDR string missing null terminator",
978        });
979    }
980    String::from_utf8(raw[..len - 1].to_vec()).map_err(|_| WireError::ValueOutOfRange {
981        message: "CDR string is not valid UTF-8",
982    })
983}
984
985#[cfg(test)]
986#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
987mod tests {
988    use super::*;
989
990    #[test]
991    fn durability_try_from_u32_rejects_unknown() {
992        assert_eq!(
993            DurabilityKind::try_from_u32(0),
994            Some(DurabilityKind::Volatile)
995        );
996        assert_eq!(
997            DurabilityKind::try_from_u32(1),
998            Some(DurabilityKind::TransientLocal)
999        );
1000        assert_eq!(
1001            DurabilityKind::try_from_u32(3),
1002            Some(DurabilityKind::Persistent)
1003        );
1004        assert_eq!(DurabilityKind::try_from_u32(99), None);
1005    }
1006
1007    #[test]
1008    fn reliability_try_from_u32_rejects_unknown() {
1009        assert_eq!(
1010            ReliabilityKind::try_from_u32(1),
1011            Some(ReliabilityKind::BestEffort)
1012        );
1013        assert_eq!(
1014            ReliabilityKind::try_from_u32(2),
1015            Some(ReliabilityKind::Reliable)
1016        );
1017        // 0 is not a valid reliability wire value.
1018        assert_eq!(ReliabilityKind::try_from_u32(0), None);
1019        assert_eq!(ReliabilityKind::try_from_u32(42), None);
1020    }
1021
1022    #[test]
1023    fn legacy_from_u32_still_defaults_for_sedp_forward_compat() {
1024        // forward-compat path: unknown → default (the SEDP parser uses this).
1025        assert_eq!(DurabilityKind::from_u32(99), DurabilityKind::Volatile);
1026        assert_eq!(ReliabilityKind::from_u32(99), ReliabilityKind::BestEffort);
1027    }
1028    use crate::wire_types::{EntityId, GuidPrefix};
1029    use alloc::vec;
1030
1031    fn sample_data() -> PublicationBuiltinTopicData {
1032        PublicationBuiltinTopicData {
1033            key: Guid::new(
1034                GuidPrefix::from_bytes([1; 12]),
1035                EntityId::user_writer_with_key([0x10, 0x20, 0x30]),
1036            ),
1037            participant_key: Guid::new(GuidPrefix::from_bytes([1; 12]), EntityId::PARTICIPANT),
1038            topic_name: "ChatterTopic".into(),
1039            type_name: "std_msgs::String".into(),
1040            durability: DurabilityKind::Volatile,
1041            reliability: ReliabilityQos {
1042                kind: ReliabilityKind::Reliable,
1043                max_blocking_time: Duration::from_secs(10),
1044            },
1045            ownership: zerodds_qos::OwnershipKind::Shared,
1046            ownership_strength: 0,
1047            liveliness: zerodds_qos::LivelinessQosPolicy::default(),
1048            deadline: zerodds_qos::DeadlineQosPolicy::default(),
1049            lifespan: zerodds_qos::LifespanQosPolicy::default(),
1050            partition: alloc::vec::Vec::new(),
1051            user_data: alloc::vec::Vec::new(),
1052            topic_data: alloc::vec::Vec::new(),
1053            group_data: alloc::vec::Vec::new(),
1054            type_information: None,
1055            data_representation: alloc::vec::Vec::new(),
1056            security_info: None,
1057            service_instance_name: None,
1058            related_entity_guid: None,
1059            topic_aliases: None,
1060            type_identifier: zerodds_types::TypeIdentifier::None,
1061            unicast_locators: alloc::vec::Vec::new(),
1062            multicast_locators: alloc::vec::Vec::new(),
1063        }
1064    }
1065
1066    #[test]
1067    fn endpoint_locators_roundtrip_le() {
1068        let mut d = sample_data();
1069        d.unicast_locators = alloc::vec![
1070            Locator::udp_v4([192, 168, 1, 5], 7411),
1071            Locator::udp_v4([10, 0, 0, 9], 7411),
1072        ];
1073        d.multicast_locators = alloc::vec![Locator::udp_v4([239, 255, 0, 2], 7401)];
1074        let bytes = d.to_pl_cdr_le().unwrap();
1075        let decoded = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap();
1076        assert_eq!(decoded.unicast_locators, d.unicast_locators);
1077        assert_eq!(decoded.multicast_locators, d.multicast_locators);
1078    }
1079
1080    #[test]
1081    fn roundtrip_le() {
1082        let d = sample_data();
1083        let bytes = d.to_pl_cdr_le().unwrap();
1084        assert_eq!(&bytes[..2], &[0x00, 0x03]); // PL_CDR_LE
1085        let decoded = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap();
1086        assert_eq!(decoded, d);
1087    }
1088
1089    #[test]
1090    fn security_info_roundtrip() {
1091        use crate::endpoint_security_info::{EndpointSecurityInfo, attrs, plugin_attrs};
1092        let mut d = sample_data();
1093        d.security_info = Some(EndpointSecurityInfo {
1094            endpoint_security_attributes: attrs::IS_VALID | attrs::IS_SUBMESSAGE_PROTECTED,
1095            plugin_endpoint_security_attributes: plugin_attrs::IS_VALID
1096                | plugin_attrs::IS_SUBMESSAGE_ENCRYPTED,
1097        });
1098        let bytes = d.to_pl_cdr_le().unwrap();
1099        let decoded = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap();
1100        assert_eq!(decoded.security_info, d.security_info);
1101    }
1102
1103    #[test]
1104    fn legacy_peer_without_security_info_parses_ok() {
1105        let d = sample_data();
1106        assert!(d.security_info.is_none());
1107        let bytes = d.to_pl_cdr_le().unwrap();
1108        let decoded = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap();
1109        assert!(decoded.security_info.is_none());
1110    }
1111
1112    #[test]
1113    fn roundtrip_utf8_topic_name() {
1114        let mut d = sample_data();
1115        d.topic_name = "Zählung".into();
1116        let bytes = d.to_pl_cdr_le().unwrap();
1117        let decoded = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap();
1118        assert_eq!(decoded.topic_name, "Zählung");
1119    }
1120
1121    #[test]
1122    fn decode_rejects_unknown_encapsulation() {
1123        let mut bytes = vec![0xFF, 0xFF, 0x00, 0x00];
1124        bytes.extend_from_slice(&[0u8; 16]);
1125        let res = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes);
1126        assert!(matches!(
1127            res,
1128            Err(WireError::UnsupportedEncapsulation { .. })
1129        ));
1130    }
1131
1132    #[test]
1133    fn decode_rejects_missing_topic_name() {
1134        let mut pl = ParameterList::new();
1135        pl.push(Parameter::new(pid::ENDPOINT_GUID, vec![0u8; 16]));
1136        pl.push(Parameter::new(
1137            pid::TYPE_NAME,
1138            encode_cdr_string_le("T").unwrap(),
1139        ));
1140        let mut bytes = Vec::new();
1141        bytes.extend_from_slice(&ENCAPSULATION_PL_CDR_LE);
1142        bytes.extend_from_slice(&[0, 0]);
1143        bytes.extend_from_slice(&pl.to_bytes(true));
1144        let res = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes);
1145        assert!(
1146            matches!(res, Err(WireError::ValueOutOfRange { message }) if message.contains("TOPIC_NAME"))
1147        );
1148    }
1149
1150    #[test]
1151    fn decode_rejects_missing_type_name() {
1152        let mut pl = ParameterList::new();
1153        pl.push(Parameter::new(pid::ENDPOINT_GUID, vec![0u8; 16]));
1154        pl.push(Parameter::new(
1155            pid::TOPIC_NAME,
1156            encode_cdr_string_le("T").unwrap(),
1157        ));
1158        let mut bytes = Vec::new();
1159        bytes.extend_from_slice(&ENCAPSULATION_PL_CDR_LE);
1160        bytes.extend_from_slice(&[0, 0]);
1161        bytes.extend_from_slice(&pl.to_bytes(true));
1162        let res = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes);
1163        assert!(
1164            matches!(res, Err(WireError::ValueOutOfRange { message }) if message.contains("TYPE_NAME"))
1165        );
1166    }
1167
1168    #[test]
1169    fn decode_rejects_missing_endpoint_guid() {
1170        let mut pl = ParameterList::new();
1171        pl.push(Parameter::new(
1172            pid::TOPIC_NAME,
1173            encode_cdr_string_le("T").unwrap(),
1174        ));
1175        pl.push(Parameter::new(
1176            pid::TYPE_NAME,
1177            encode_cdr_string_le("U").unwrap(),
1178        ));
1179        let mut bytes = Vec::new();
1180        bytes.extend_from_slice(&ENCAPSULATION_PL_CDR_LE);
1181        bytes.extend_from_slice(&[0, 0]);
1182        bytes.extend_from_slice(&pl.to_bytes(true));
1183        let res = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes);
1184        assert!(
1185            matches!(res, Err(WireError::ValueOutOfRange { message }) if message.contains("ENDPOINT_GUID"))
1186        );
1187    }
1188
1189    #[test]
1190    fn unknown_pids_are_skipped() {
1191        let mut bytes = sample_data().to_pl_cdr_le().unwrap();
1192        // Insert a new unknown PID (0x7FFF, 4 byte) before the sentinel.
1193        // The sentinel parameter is the last 4 bytes.
1194        let sentinel_pos = bytes.len() - 4;
1195        let mut inject = vec![0xFFu8, 0x7F, 4, 0, 0xDE, 0xAD, 0xBE, 0xEF];
1196        inject.extend_from_slice(&bytes[sentinel_pos..]);
1197        bytes.truncate(sentinel_pos);
1198        bytes.extend_from_slice(&inject);
1199        let decoded = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap();
1200        assert_eq!(decoded, sample_data());
1201    }
1202
1203    #[test]
1204    fn inject_pid_shm_locator_appends_before_sentinel() {
1205        // Locator body: 16 byte (four u32) + 8 byte CDR string "x"
1206        // (4 byte len = 2, 1 byte 'x', 1 byte null, 2 byte pad).
1207        let mut locator = Vec::new();
1208        locator.extend_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); // hostname_hash
1209        locator.extend_from_slice(&1000u32.to_le_bytes()); // uid
1210        locator.extend_from_slice(&64u32.to_le_bytes()); // slot_count
1211        locator.extend_from_slice(&4096u32.to_le_bytes()); // slot_size
1212        // CDR string "/dev/shm/zd-1\0":
1213        let path = "/dev/shm/zd-1";
1214        locator.extend_from_slice(&((path.len() as u32) + 1).to_le_bytes());
1215        locator.extend_from_slice(path.as_bytes());
1216        locator.push(0);
1217        // Pad to a 4-byte boundary.
1218        let pad = (4 - locator.len() % 4) % 4;
1219        locator.resize(locator.len() + pad, 0);
1220
1221        let mut bytes = sample_data().to_pl_cdr_le().unwrap();
1222        let len_before = bytes.len();
1223        super::inject_pid_shm_locator(&mut bytes, &locator).unwrap();
1224        // The bytes have grown (PID header 4 + locator).
1225        assert!(bytes.len() > len_before);
1226        // And still decode — the vendor PID is ignored as unknown (no
1227        // MUST_UNDERSTAND bit), the rest stays identical.
1228        let decoded = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap();
1229        assert_eq!(decoded, sample_data());
1230        // Sanity: PID 0x8001 is actually present.
1231        let pid_found = bytes.windows(2).any(|w| w == 0x8001u16.to_le_bytes());
1232        assert!(pid_found, "PID_SHM_LOCATOR should appear in bytes");
1233    }
1234
1235    #[test]
1236    fn inject_pid_shm_locator_rejects_missing_sentinel() {
1237        let mut bytes = vec![0u8; 8];
1238        let res = super::inject_pid_shm_locator(&mut bytes, &[0u8; 16]);
1239        assert!(res.is_err());
1240    }
1241
1242    #[test]
1243    fn inject_pid_shm_locator_rejects_too_short() {
1244        let mut bytes = vec![0u8, 1u8];
1245        let res = super::inject_pid_shm_locator(&mut bytes, &[0u8; 16]);
1246        assert!(res.is_err());
1247    }
1248
1249    #[test]
1250    fn participant_key_fallback_when_pid_missing() {
1251        // Build a PL without PARTICIPANT_GUID: the decoder should derive
1252        // participant_key from ENDPOINT_GUID.prefix + PARTICIPANT.
1253        let d = sample_data();
1254        let mut pl = ParameterList::new();
1255        pl.push(Parameter::new(
1256            pid::ENDPOINT_GUID,
1257            d.key.to_bytes().to_vec(),
1258        ));
1259        pl.push(Parameter::new(
1260            pid::TOPIC_NAME,
1261            encode_cdr_string_le(&d.topic_name).unwrap(),
1262        ));
1263        pl.push(Parameter::new(
1264            pid::TYPE_NAME,
1265            encode_cdr_string_le(&d.type_name).unwrap(),
1266        ));
1267        let mut bytes = Vec::new();
1268        bytes.extend_from_slice(&ENCAPSULATION_PL_CDR_LE);
1269        bytes.extend_from_slice(&[0, 0]);
1270        bytes.extend_from_slice(&pl.to_bytes(true));
1271        let decoded = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap();
1272        assert_eq!(decoded.participant_key.prefix, d.key.prefix);
1273        assert_eq!(decoded.participant_key.entity_id, EntityId::PARTICIPANT);
1274    }
1275
1276    #[test]
1277    fn durability_kind_from_u32_unknown_defaults_volatile() {
1278        assert_eq!(DurabilityKind::from_u32(0), DurabilityKind::Volatile);
1279        assert_eq!(DurabilityKind::from_u32(1), DurabilityKind::TransientLocal);
1280        assert_eq!(DurabilityKind::from_u32(999), DurabilityKind::Volatile);
1281    }
1282
1283    #[test]
1284    fn rpc_discovery_pids_roundtrip() {
1285        let mut d = sample_data();
1286        d.service_instance_name = Some("CalcInstance-1".into());
1287        d.related_entity_guid = Some(Guid::new(
1288            crate::wire_types::GuidPrefix::from_bytes([7; 12]),
1289            crate::wire_types::EntityId::user_reader_with_key([0xAA, 0xBB, 0xCC]),
1290        ));
1291        d.topic_aliases = Some(alloc::vec!["LegacyCalc_Request".into(), "v2_Req".into()]);
1292
1293        let bytes = d.to_pl_cdr_le().unwrap();
1294        let decoded = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap();
1295        assert_eq!(decoded.service_instance_name, d.service_instance_name);
1296        assert_eq!(decoded.related_entity_guid, d.related_entity_guid);
1297        assert_eq!(decoded.topic_aliases, d.topic_aliases);
1298    }
1299
1300    #[test]
1301    fn rpc_pids_optional_legacy_peer_parses_ok() {
1302        let d = sample_data();
1303        assert!(d.service_instance_name.is_none());
1304        assert!(d.related_entity_guid.is_none());
1305        assert!(d.topic_aliases.is_none());
1306        let bytes = d.to_pl_cdr_le().unwrap();
1307        let decoded = PublicationBuiltinTopicData::from_pl_cdr_le(&bytes).unwrap();
1308        assert!(decoded.service_instance_name.is_none());
1309        assert!(decoded.related_entity_guid.is_none());
1310        assert!(decoded.topic_aliases.is_none());
1311    }
1312
1313    #[test]
1314    fn rpc_pid_constants_in_emitted_bytes() {
1315        // Cyclone-compat snapshot: PID 0x0080..0x0083 must appear
1316        // byte-exact in the stream, otherwise a Cyclone reader cannot
1317        // dispatch them.
1318        let mut d = sample_data();
1319        d.service_instance_name = Some("X".into());
1320        d.related_entity_guid = Some(Guid::new(
1321            crate::wire_types::GuidPrefix::from_bytes([1; 12]),
1322            crate::wire_types::EntityId::PARTICIPANT,
1323        ));
1324        d.topic_aliases = Some(alloc::vec!["A".into()]);
1325        let bytes = d.to_pl_cdr_le().unwrap();
1326        // PIDs are 2-byte little-endian.
1327        let mut found_080 = false;
1328        let mut found_081 = false;
1329        let mut found_082 = false;
1330        for w in bytes.windows(2) {
1331            if w == [0x80, 0x00] {
1332                found_080 = true;
1333            }
1334            if w == [0x81, 0x00] {
1335                found_081 = true;
1336            }
1337            if w == [0x82, 0x00] {
1338                found_082 = true;
1339            }
1340        }
1341        assert!(found_080 && found_081 && found_082);
1342    }
1343
1344    #[test]
1345    fn reliability_kind_from_u32_unknown_defaults_best_effort() {
1346        assert_eq!(ReliabilityKind::from_u32(1), ReliabilityKind::BestEffort);
1347        assert_eq!(ReliabilityKind::from_u32(2), ReliabilityKind::Reliable);
1348        assert_eq!(ReliabilityKind::from_u32(999), ReliabilityKind::BestEffort);
1349    }
1350}