Skip to main content

zerodds_rtps/
property_list.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! PropertyList Wire-Format fuer `PID_PROPERTY_LIST` (0x0059).
4//!
5//! Spec OMG DDS-Security 1.1 §7.2.1 + DDSI-RTPS 2.5 §9.6.4:
6//! ```text
7//! PropertyQosPolicy {
8//!     sequence<Property_t>       value;          // name/value
9//!     sequence<BinaryProperty_t> binary_value;   // name/bytes
10//! };
11//! Property_t { string name; string value; };
12//! ```
13//!
14//! CDR-Encoding (XCDR1 Classic-CDR, DDSI-RTPS 2.5 §10.2):
15//! ```text
16//!   u32  n_props
17//!   [ Property ] * n_props
18//!   u32  n_binary         // immer 0 fuer unsere Zwecke
19//!
20//! Property:
21//!   align 4
22//!   u32  name_len         // inkl. null-terminator
23//!   u8   name[name_len]
24//!   align 4
25//!   u32  value_len
26//!   u8   value[value_len]
27//! ```
28//!
29//! Dieses Modul ist **Wire-only** — es kennt keine Security-Semantik
30//! (propagate-Flag, Plugin-Klassen, etc.). Die Policy-Schicht
31//! ([`zerodds-security-runtime`]) baut auf diesem Wire-Typ auf.
32
33extern crate alloc;
34use alloc::string::{String, ToString};
35use alloc::vec::Vec;
36
37use crate::error::WireError;
38
39/// DoS-Cap fuer die Anzahl Properties in einer PropertyList (amplifiziert
40/// sonst via SPDP-Broadcast). 1024 passt fuer jede realistische
41/// Security-Plugin-Konfiguration.
42pub const MAX_PROPERTIES: usize = 1_024;
43
44/// DoS-Cap fuer die Laenge von name+value in Bytes (verhindert einen
45/// Peer, der eine einzelne 4-GiB-Property schickt).
46pub const MAX_PROPERTY_STRING_LEN: usize = 64 * 1024;
47
48/// Ein einzelner Property-Eintrag auf dem Wire. Beide Felder sind
49/// UTF-8-Strings ohne inneren Null-Byte (Spec-Constraint des
50/// CDR-String-Typs).
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct WireProperty {
53    /// Name (reverse-DNS Convention, z.B. `dds.sec.auth.plugin_class`).
54    pub name: String,
55    /// Wert (opaker UTF-8-String).
56    pub value: String,
57}
58
59impl WireProperty {
60    /// Konstruktor.
61    #[must_use]
62    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
63        Self {
64            name: name.into(),
65            value: value.into(),
66        }
67    }
68}
69
70/// PropertyList-Snapshot fuer die Wire-Ebene. Die Reihenfolge wird
71/// beim Encode/Decode beibehalten — Caller, die Duplikat-Namen
72/// vermeiden wollen, muessen das selbst durchsetzen.
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct WirePropertyList {
75    /// Liste der Properties in wire-Reihenfolge.
76    pub entries: Vec<WireProperty>,
77}
78
79impl WirePropertyList {
80    /// Leere Liste.
81    #[must_use]
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Liefert `true` wenn kein Property vorhanden.
87    #[must_use]
88    pub fn is_empty(&self) -> bool {
89        self.entries.is_empty()
90    }
91
92    /// Anzahl Properties.
93    #[must_use]
94    pub fn len(&self) -> usize {
95        self.entries.len()
96    }
97
98    /// Fuegt ein Property an. Ueberschreibt **nicht** bei Dublette —
99    /// Wire-Semantik: letzter Eintrag gewinnt beim Lookup.
100    pub fn push(&mut self, prop: WireProperty) {
101        self.entries.push(prop);
102    }
103
104    /// Builder-Variante fuer [`Self::push`].
105    #[must_use]
106    pub fn with(mut self, prop: WireProperty) -> Self {
107        self.push(prop);
108        self
109    }
110
111    /// Liefert den Wert zum letzten Eintrag mit diesem Namen (Spec-
112    /// Semantik: "last value wins").
113    #[must_use]
114    pub fn get(&self, name: &str) -> Option<&str> {
115        self.entries
116            .iter()
117            .rev()
118            .find(|p| p.name == name)
119            .map(|p| p.value.as_str())
120    }
121
122    /// Encode zur Byte-Sequenz, die direkt als Value eines
123    /// `PID_PROPERTY_LIST`-Parameters genutzt wird. `BinaryPropertySeq`
124    /// wird immer als `count=0` angehaengt.
125    ///
126    /// # Errors
127    /// * `ValueOutOfRange` wenn die Anzahl Properties
128    ///   [`MAX_PROPERTIES`] ueberschreitet.
129    /// * `ValueOutOfRange` wenn name/value ueber
130    ///   [`MAX_PROPERTY_STRING_LEN`] liegen.
131    pub fn encode(&self, little_endian: bool) -> Result<Vec<u8>, WireError> {
132        if self.entries.len() > MAX_PROPERTIES {
133            return Err(WireError::ValueOutOfRange {
134                message: "PropertyList: exceeds MAX_PROPERTIES",
135            });
136        }
137        let mut out = Vec::new();
138        let n = u32::try_from(self.entries.len()).map_err(|_| WireError::ValueOutOfRange {
139            message: "PropertyList: entry count exceeds u32",
140        })?;
141        write_u32(&mut out, n, little_endian);
142        for p in &self.entries {
143            check_len(&p.name)?;
144            check_len(&p.value)?;
145            write_cdr_string(&mut out, &p.name, little_endian)?;
146            align4(&mut out);
147            write_cdr_string(&mut out, &p.value, little_endian)?;
148            align4(&mut out);
149        }
150        // BinaryPropertySeq: count = 0, keine Eintraege.
151        write_u32(&mut out, 0, little_endian);
152        Ok(out)
153    }
154
155    /// Decode aus dem Value-Byte-Slice eines `PID_PROPERTY_LIST`-
156    /// Parameters.
157    ///
158    /// # Errors
159    /// * `UnexpectedEof` bei truncated Eingabe.
160    /// * `ValueOutOfRange` bei verletzten DoS-Caps.
161    pub fn decode(bytes: &[u8], little_endian: bool) -> Result<Self, WireError> {
162        let mut pos = 0usize;
163        let n_props = read_u32(bytes, &mut pos, little_endian)? as usize;
164        if n_props > MAX_PROPERTIES {
165            return Err(WireError::ValueOutOfRange {
166                message: "PropertyList: count exceeds MAX_PROPERTIES",
167            });
168        }
169        let mut entries = Vec::with_capacity(n_props);
170        for _ in 0..n_props {
171            let name = read_cdr_string(bytes, &mut pos, little_endian)?;
172            align4_read(&mut pos);
173            let value = read_cdr_string(bytes, &mut pos, little_endian)?;
174            align4_read(&mut pos);
175            entries.push(WireProperty { name, value });
176        }
177        // BinaryPropertySeq lesen und verwerfen (wir persistieren die
178        // binary-Eintraege derzeit nicht — kein Security-Use-Case in
179        // Stufe 2).
180        let n_binary = read_u32(bytes, &mut pos, little_endian)? as usize;
181        if n_binary > MAX_PROPERTIES {
182            return Err(WireError::ValueOutOfRange {
183                message: "PropertyList: binary count exceeds MAX_PROPERTIES",
184            });
185        }
186        for _ in 0..n_binary {
187            // Binary: string name + sequence<u8> value.
188            let _ = read_cdr_string(bytes, &mut pos, little_endian)?;
189            align4_read(&mut pos);
190            let vlen = read_u32(bytes, &mut pos, little_endian)? as usize;
191            if vlen > MAX_PROPERTY_STRING_LEN {
192                return Err(WireError::ValueOutOfRange {
193                    message: "PropertyList: binary value exceeds cap",
194                });
195            }
196            if bytes.len() < pos.saturating_add(vlen) {
197                return Err(WireError::UnexpectedEof {
198                    needed: vlen,
199                    offset: pos,
200                });
201            }
202            pos += vlen;
203            align4_read(&mut pos);
204        }
205        Ok(Self { entries })
206    }
207}
208
209// ============================================================================
210// internals
211// ============================================================================
212
213fn check_len(s: &str) -> Result<(), WireError> {
214    if s.len() > MAX_PROPERTY_STRING_LEN {
215        return Err(WireError::ValueOutOfRange {
216            message: "PropertyList: string exceeds MAX_PROPERTY_STRING_LEN",
217        });
218    }
219    if s.as_bytes().contains(&0) {
220        return Err(WireError::ValueOutOfRange {
221            message: "PropertyList: string contains inner null byte",
222        });
223    }
224    Ok(())
225}
226
227fn align4(out: &mut Vec<u8>) {
228    while out.len() % 4 != 0 {
229        out.push(0);
230    }
231}
232
233fn align4_read(pos: &mut usize) {
234    *pos = pos.wrapping_add((4 - (*pos % 4)) % 4);
235}
236
237fn write_u32(out: &mut Vec<u8>, v: u32, le: bool) {
238    let bytes = if le { v.to_le_bytes() } else { v.to_be_bytes() };
239    out.extend_from_slice(&bytes);
240}
241
242fn read_u32(bytes: &[u8], pos: &mut usize, le: bool) -> Result<u32, WireError> {
243    if bytes.len() < pos.saturating_add(4) {
244        return Err(WireError::UnexpectedEof {
245            needed: 4,
246            offset: *pos,
247        });
248    }
249    let mut b = [0u8; 4];
250    b.copy_from_slice(&bytes[*pos..*pos + 4]);
251    *pos += 4;
252    Ok(if le {
253        u32::from_le_bytes(b)
254    } else {
255        u32::from_be_bytes(b)
256    })
257}
258
259fn write_cdr_string(out: &mut Vec<u8>, s: &str, le: bool) -> Result<(), WireError> {
260    let bytes = s.as_bytes();
261    let len =
262        u32::try_from(bytes.len().saturating_add(1)).map_err(|_| WireError::ValueOutOfRange {
263            message: "CDR string length exceeds u32::MAX",
264        })?;
265    write_u32(out, len, le);
266    out.extend_from_slice(bytes);
267    out.push(0);
268    Ok(())
269}
270
271fn read_cdr_string(bytes: &[u8], pos: &mut usize, le: bool) -> Result<String, WireError> {
272    let len = read_u32(bytes, pos, le)? as usize;
273    if len == 0 {
274        return Err(WireError::ValueOutOfRange {
275            message: "CDR string length 0 (missing null terminator)",
276        });
277    }
278    if len > MAX_PROPERTY_STRING_LEN {
279        return Err(WireError::ValueOutOfRange {
280            message: "CDR string exceeds MAX_PROPERTY_STRING_LEN",
281        });
282    }
283    if bytes.len() < pos.saturating_add(len) {
284        return Err(WireError::UnexpectedEof {
285            needed: len,
286            offset: *pos,
287        });
288    }
289    // Null-terminator gehoert in die Laenge — ihn abschneiden.
290    let text_bytes = &bytes[*pos..*pos + len - 1];
291    if bytes[*pos + len - 1] != 0 {
292        return Err(WireError::ValueOutOfRange {
293            message: "CDR string missing null terminator",
294        });
295    }
296    let s = core::str::from_utf8(text_bytes)
297        .map_err(|_| WireError::ValueOutOfRange {
298            message: "CDR string is not valid UTF-8",
299        })?
300        .to_string();
301    *pos += len;
302    Ok(s)
303}
304
305// ============================================================================
306// Tests
307// ============================================================================
308
309#[cfg(test)]
310mod tests {
311    #![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
312    use super::*;
313
314    #[test]
315    fn empty_list_roundtrip_le() {
316        let list = WirePropertyList::new();
317        let bytes = list.encode(true).unwrap();
318        // 2 * u32(0) = 8 bytes (n_props=0, n_binary=0)
319        assert_eq!(bytes, [0u8; 8]);
320        let decoded = WirePropertyList::decode(&bytes, true).unwrap();
321        assert_eq!(decoded, list);
322    }
323
324    #[test]
325    fn single_property_roundtrip_le() {
326        let list = WirePropertyList::new().with(WireProperty::new(
327            "dds.sec.auth.plugin_class",
328            "DDS:Auth:PKI-DH:1.2",
329        ));
330        let bytes = list.encode(true).unwrap();
331        let decoded = WirePropertyList::decode(&bytes, true).unwrap();
332        assert_eq!(decoded, list);
333    }
334
335    #[test]
336    fn multi_property_roundtrip_le() {
337        let list = WirePropertyList::new()
338            .with(WireProperty::new(
339                "dds.sec.auth.plugin_class",
340                "DDS:Auth:PKI-DH:1.2",
341            ))
342            .with(WireProperty::new(
343                "dds.sec.crypto.plugin_class",
344                "DDS:Crypto:AES-GCM-GMAC:1.2",
345            ))
346            .with(WireProperty::new(
347                "zerodds.sec.supported_suites",
348                "AES_128_GCM,AES_256_GCM,HMAC_SHA256",
349            ))
350            .with(WireProperty::new(
351                "zerodds.sec.offered_protection",
352                "ENCRYPT",
353            ));
354        let bytes = list.encode(true).unwrap();
355        let decoded = WirePropertyList::decode(&bytes, true).unwrap();
356        assert_eq!(decoded, list);
357    }
358
359    #[test]
360    fn multi_property_roundtrip_be() {
361        let list = WirePropertyList::new()
362            .with(WireProperty::new("a", "b"))
363            .with(WireProperty::new("cc", "dd"));
364        let bytes = list.encode(false).unwrap();
365        let decoded = WirePropertyList::decode(&bytes, false).unwrap();
366        assert_eq!(decoded, list);
367    }
368
369    #[test]
370    fn encode_pads_strings_to_4_between_name_and_value() {
371        let list = WirePropertyList::new().with(WireProperty::new("ab", "cd"));
372        let bytes = list.encode(true).unwrap();
373        // Layout LE:
374        //   04 00 00 00           n_props = 1
375        //   03 00 00 00           name_len = 3 ("ab"+null)
376        //   'a' 'b' 0 <pad-1>     → pad to 4
377        //   03 00 00 00           value_len = 3
378        //   'c' 'd' 0 <pad-1>
379        //   00 00 00 00           n_binary = 0
380        assert_eq!(bytes.len(), 4 + 4 + 4 + 4 + 4 + 4);
381    }
382
383    #[test]
384    fn get_returns_last_value_for_duplicate_names() {
385        let list = WirePropertyList::new()
386            .with(WireProperty::new("dup", "first"))
387            .with(WireProperty::new("dup", "second"));
388        assert_eq!(list.get("dup"), Some("second"));
389    }
390
391    #[test]
392    fn get_none_for_unknown_name() {
393        let list = WirePropertyList::new().with(WireProperty::new("a", "b"));
394        assert!(list.get("x").is_none());
395    }
396
397    #[test]
398    fn decode_rejects_truncated_count() {
399        let err = WirePropertyList::decode(&[0, 0], true).unwrap_err();
400        assert!(matches!(err, WireError::UnexpectedEof { .. }));
401    }
402
403    #[test]
404    fn decode_rejects_count_above_cap() {
405        // n_props = u32::MAX — sollte vor der Allozierung abgelehnt werden.
406        let mut bytes = Vec::new();
407        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
408        let err = WirePropertyList::decode(&bytes, true).unwrap_err();
409        assert!(matches!(err, WireError::ValueOutOfRange { .. }));
410    }
411
412    #[test]
413    fn decode_rejects_truncated_value_string() {
414        let mut bytes = Vec::new();
415        bytes.extend_from_slice(&1u32.to_le_bytes()); // n_props = 1
416        bytes.extend_from_slice(&3u32.to_le_bytes()); // name_len = 3
417        bytes.extend_from_slice(b"ab\0\0"); // name + pad
418        bytes.extend_from_slice(&10u32.to_le_bytes()); // value_len = 10, aber keine bytes mehr
419        let err = WirePropertyList::decode(&bytes, true).unwrap_err();
420        assert!(matches!(err, WireError::UnexpectedEof { .. }));
421    }
422
423    #[test]
424    fn decode_accepts_nonzero_binary_sequence() {
425        // Wir persistieren binary nicht, muessen sie aber konsumieren.
426        let mut bytes = Vec::new();
427        bytes.extend_from_slice(&0u32.to_le_bytes()); // n_props = 0
428        bytes.extend_from_slice(&1u32.to_le_bytes()); // n_binary = 1
429        bytes.extend_from_slice(&4u32.to_le_bytes()); // binary name_len = 4 ("ab"+null gepadded)
430        bytes.extend_from_slice(b"bin\0"); // name "bin" + null
431        bytes.extend_from_slice(&3u32.to_le_bytes()); // value_len = 3
432        bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0x00]); // bytes + pad
433        let decoded = WirePropertyList::decode(&bytes, true).unwrap();
434        assert!(decoded.is_empty(), "binary seq wird still konsumiert");
435    }
436
437    #[test]
438    fn encode_rejects_inner_null_byte_in_name() {
439        let list = WirePropertyList::new().with(WireProperty::new("a\0b", "v"));
440        assert!(list.encode(true).is_err());
441    }
442
443    #[test]
444    fn encode_rejects_inner_null_byte_in_value() {
445        let list = WirePropertyList::new().with(WireProperty::new("a", "v\0v"));
446        assert!(list.encode(true).is_err());
447    }
448
449    #[test]
450    fn encode_enforces_max_properties() {
451        let mut list = WirePropertyList::new();
452        for _ in 0..(MAX_PROPERTIES + 1) {
453            list.push(WireProperty::new("k", "v"));
454        }
455        assert!(list.encode(true).is_err());
456    }
457
458    #[test]
459    fn decode_rejects_missing_null_terminator() {
460        // name_len sagt 4, aber letzes byte ist kein null
461        let mut bytes = Vec::new();
462        bytes.extend_from_slice(&1u32.to_le_bytes()); // n_props = 1
463        bytes.extend_from_slice(&4u32.to_le_bytes()); // name_len = 4
464        bytes.extend_from_slice(b"abcd"); // kein null am Ende
465        bytes.extend_from_slice(&2u32.to_le_bytes()); // value_len = 2
466        bytes.extend_from_slice(b"v\0\0\0"); // wuerde kompletten sein
467        bytes.extend_from_slice(&0u32.to_le_bytes()); // n_binary = 0
468        let err = WirePropertyList::decode(&bytes, true).unwrap_err();
469        assert!(matches!(err, WireError::ValueOutOfRange { .. }));
470    }
471
472    #[test]
473    fn decode_rejects_non_utf8_name() {
474        let mut bytes = Vec::new();
475        bytes.extend_from_slice(&1u32.to_le_bytes()); // n_props = 1
476        bytes.extend_from_slice(&3u32.to_le_bytes()); // name_len = 3
477        bytes.extend_from_slice(&[0xFF, 0xFE, 0x00, 0x00]); // invalid UTF-8 + null + pad
478        bytes.extend_from_slice(&2u32.to_le_bytes()); // value_len = 2
479        bytes.extend_from_slice(b"v\0\0\0");
480        bytes.extend_from_slice(&0u32.to_le_bytes());
481        let err = WirePropertyList::decode(&bytes, true).unwrap_err();
482        assert!(matches!(err, WireError::ValueOutOfRange { .. }));
483    }
484
485    #[test]
486    fn len_and_is_empty_consistency() {
487        let mut list = WirePropertyList::new();
488        assert_eq!(list.len(), 0);
489        assert!(list.is_empty());
490        list.push(WireProperty::new("a", "b"));
491        assert_eq!(list.len(), 1);
492        assert!(!list.is_empty());
493    }
494
495    #[test]
496    fn encode_rejects_name_above_max_string_len() {
497        let big = "x".repeat(MAX_PROPERTY_STRING_LEN + 1);
498        let list = WirePropertyList::new().with(WireProperty::new(big, "v"));
499        assert!(list.encode(true).is_err());
500    }
501
502    #[test]
503    fn decode_rejects_binary_count_above_cap() {
504        let mut bytes = Vec::new();
505        bytes.extend_from_slice(&0u32.to_le_bytes()); // n_props = 0
506        bytes.extend_from_slice(&u32::MAX.to_le_bytes()); // n_binary = u32::MAX
507        let err = WirePropertyList::decode(&bytes, true).unwrap_err();
508        assert!(matches!(err, WireError::ValueOutOfRange { .. }));
509    }
510
511    #[test]
512    fn decode_rejects_binary_value_above_cap() {
513        let mut bytes = Vec::new();
514        bytes.extend_from_slice(&0u32.to_le_bytes()); // n_props = 0
515        bytes.extend_from_slice(&1u32.to_le_bytes()); // n_binary = 1
516        bytes.extend_from_slice(&4u32.to_le_bytes()); // binary name_len = 4
517        bytes.extend_from_slice(b"bin\0"); // name + null
518        bytes.extend_from_slice(&(MAX_PROPERTY_STRING_LEN as u32 + 1).to_le_bytes());
519        // Keine echten Value-Bytes, aber wir erwarten Cap-Check vor EOF
520        let err = WirePropertyList::decode(&bytes, true).unwrap_err();
521        assert!(matches!(err, WireError::ValueOutOfRange { .. }));
522    }
523
524    #[test]
525    fn decode_rejects_truncated_binary_value() {
526        let mut bytes = Vec::new();
527        bytes.extend_from_slice(&0u32.to_le_bytes()); // n_props = 0
528        bytes.extend_from_slice(&1u32.to_le_bytes()); // n_binary = 1
529        bytes.extend_from_slice(&4u32.to_le_bytes()); // binary name_len = 4
530        bytes.extend_from_slice(b"bin\0");
531        bytes.extend_from_slice(&8u32.to_le_bytes()); // value_len = 8
532        bytes.extend_from_slice(&[0xAA, 0xBB]); // nur 2 byte da
533        let err = WirePropertyList::decode(&bytes, true).unwrap_err();
534        assert!(matches!(err, WireError::UnexpectedEof { .. }));
535    }
536
537    #[test]
538    fn csv_suites_value_roundtrips() {
539        let list = WirePropertyList::new().with(WireProperty::new(
540            "zerodds.sec.supported_suites",
541            "AES_128_GCM,AES_256_GCM,HMAC_SHA256",
542        ));
543        let bytes = list.encode(true).unwrap();
544        let decoded = WirePropertyList::decode(&bytes, true).unwrap();
545        assert_eq!(
546            decoded.get("zerodds.sec.supported_suites"),
547            Some("AES_128_GCM,AES_256_GCM,HMAC_SHA256")
548        );
549    }
550}