Skip to main content

zerodds_security_runtime/
data_tagging.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Builtin DataTagging-Plugin (OMG DDS-Security 1.2 §12).
5//!
6//! Implementiert das [`zerodds_security::DataTaggingPlugin`]-SPI als
7//! produktiven Builtin. Tags sind Application-Level-Labels
8//! (Classification-Marker, Sensitivity, etc.), die per Endpoint-GUID
9//! verwaltet, ueber SEDP via `PID_PROPERTY_LIST` propagiert und
10//! Subscriber-seitig auf Match geprueft werden.
11//!
12//! # Wire-Pfad
13//!
14//! Tags werden als `WireProperty`-Eintraege mit Namespace-Prefix
15//! `dds.sec.data_tags.` in die existierende [`WirePropertyList`]
16//! eingebettet — d.h. wir reuten den bereits in SPDP/SEDP propagierten
17//! `PID_PROPERTY_LIST`-Parameter, anstatt einen neuen PID einzufuehren.
18//! Das passt zu Cyclones/RTI-Verhalten, das Tags ebenfalls via
19//! `PID_PROPERTY_LIST` traegt (Cyclone DDS Security §8 doc).
20//!
21//! # Match-Predicate
22//!
23//! Default-Predicate ist Subset-Match:
24//! * Subscriber ohne Tags → akzeptiert jeden Publisher (Wildcard).
25//! * Subscriber mit Tags → jeder Tag (name+value) muss exakt im
26//!   Publisher-Tag-Set vorkommen.
27//! * Unknown Tag-Name auf Subscriber-Seite, der nicht beim Publisher
28//!   existiert → Reject.
29//!
30//! Spec-konform und einfach genug fuer NGVA/FACE-Pilot-Setups; komplexe
31//! Predicates (range/regex) werden via Custom-`DataTaggingPlugin`
32//! abgedeckt.
33//!
34//! # Beispiel
35//!
36//! ```no_run
37//! use zerodds_security::data_tagging::{DataTag, DataTaggingPlugin};
38//! use zerodds_security_runtime::data_tagging::BuiltinDataTaggingPlugin;
39//!
40//! let mut plugin = BuiltinDataTaggingPlugin::new();
41//! let pub_guid = [0xAA; 16];
42//! plugin.set_tags(
43//!     pub_guid,
44//!     vec![DataTag { name: "classification".into(), value: "secret".into() }],
45//! );
46//!
47//! let sub_tags = vec![DataTag { name: "classification".into(), value: "secret".into() }];
48//! assert!(BuiltinDataTaggingPlugin::tags_match(&plugin.get_tags(pub_guid), &sub_tags));
49//! ```
50//!
51//! zerodds-lint: allow no_dyn_in_safe
52//! (Plugin-Trait-Object via `Box<dyn DataTaggingPlugin>`.)
53
54extern crate alloc;
55
56use alloc::collections::BTreeMap;
57use alloc::string::String;
58use alloc::vec::Vec;
59
60use zerodds_rtps::property_list::{WireProperty, WirePropertyList};
61use zerodds_security::data_tagging::{DataTag, DataTaggingPlugin};
62
63/// Property-Namespace fuer Tag-Wire-Encoding. Jeder Tag erscheint als
64/// ein `WireProperty` mit `name = TAG_PROPERTY_PREFIX + tag.name`,
65/// `value = tag.value`. Andere Properties (auth-class, suite-list, …)
66/// bleiben unangetastet.
67pub const TAG_PROPERTY_PREFIX: &str = "dds.sec.data_tags.";
68
69/// Builtin DataTagging-Plugin fuer Spec §12.
70#[derive(Debug, Default)]
71pub struct BuiltinDataTaggingPlugin {
72    tags: BTreeMap<[u8; 16], Vec<DataTag>>,
73}
74
75impl BuiltinDataTaggingPlugin {
76    /// Konstruktor — Plugin startet ohne registrierte Endpoints.
77    #[must_use]
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Default Subset-Match-Predicate (siehe Modul-Doku).
83    #[must_use]
84    pub fn tags_match(publisher: &[DataTag], subscriber: &[DataTag]) -> bool {
85        if subscriber.is_empty() {
86            return true;
87        }
88        subscriber.iter().all(|s| publisher.iter().any(|p| p == s))
89    }
90
91    /// Encodiert eine Tag-Liste als `WireProperty`-Sequenz zur Aufnahme
92    /// in eine [`WirePropertyList`]. Tags mit doppeltem Namen werden
93    /// stabil in Eingabe-Reihenfolge geschrieben — die `last value
94    /// wins`-Semantik der PropertyList wird damit konsistent.
95    #[must_use]
96    pub fn encode_tags(tags: &[DataTag]) -> Vec<WireProperty> {
97        tags.iter()
98            .map(|t| {
99                let mut name = String::with_capacity(TAG_PROPERTY_PREFIX.len() + t.name.len());
100                name.push_str(TAG_PROPERTY_PREFIX);
101                name.push_str(&t.name);
102                WireProperty::new(name, t.value.clone())
103            })
104            .collect()
105    }
106
107    /// Filtert eine [`WirePropertyList`] nach Tag-Properties (Prefix-
108    /// Match) und liefert die de-prefixed Tag-Liste. Andere Properties
109    /// werden ignoriert.
110    #[must_use]
111    pub fn decode_tags(list: &WirePropertyList) -> Vec<DataTag> {
112        list.entries
113            .iter()
114            .filter_map(|p| {
115                p.name.strip_prefix(TAG_PROPERTY_PREFIX).map(|n| DataTag {
116                    name: n.to_string(),
117                    value: p.value.clone(),
118                })
119            })
120            .collect()
121    }
122}
123
124impl DataTaggingPlugin for BuiltinDataTaggingPlugin {
125    fn set_tags(&mut self, endpoint_guid: [u8; 16], tags: Vec<DataTag>) {
126        if tags.is_empty() {
127            self.tags.remove(&endpoint_guid);
128        } else {
129            self.tags.insert(endpoint_guid, tags);
130        }
131    }
132
133    fn get_tags(&self, endpoint_guid: [u8; 16]) -> Vec<DataTag> {
134        self.tags.get(&endpoint_guid).cloned().unwrap_or_default()
135    }
136
137    fn plugin_class_id(&self) -> &str {
138        "DDS:Tagging:Builtin"
139    }
140}
141
142// ============================================================================
143// Tests
144// ============================================================================
145
146#[cfg(test)]
147#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
148mod tests {
149    use super::*;
150
151    fn tag(name: &str, value: &str) -> DataTag {
152        DataTag {
153            name: name.into(),
154            value: value.into(),
155        }
156    }
157
158    #[test]
159    fn set_get_roundtrip() {
160        let mut p = BuiltinDataTaggingPlugin::new();
161        let g = [0xAA; 16];
162        p.set_tags(g, vec![tag("classification", "secret")]);
163        assert_eq!(p.get_tags(g), vec![tag("classification", "secret")]);
164    }
165
166    #[test]
167    fn unknown_endpoint_returns_empty() {
168        let p = BuiltinDataTaggingPlugin::new();
169        assert!(p.get_tags([0xCC; 16]).is_empty());
170    }
171
172    #[test]
173    fn set_empty_clears_existing() {
174        let mut p = BuiltinDataTaggingPlugin::new();
175        let g = [0xBB; 16];
176        p.set_tags(g, vec![tag("a", "1")]);
177        p.set_tags(g, Vec::new());
178        assert!(p.get_tags(g).is_empty());
179    }
180
181    #[test]
182    fn match_empty_subscriber_is_wildcard() {
183        // Subscriber ohne Tags akzeptiert jeden Publisher — auch ohne Tags.
184        assert!(BuiltinDataTaggingPlugin::tags_match(&[], &[]));
185        assert!(BuiltinDataTaggingPlugin::tags_match(
186            &[tag("classification", "secret")],
187            &[],
188        ));
189    }
190
191    #[test]
192    fn match_subset_passes() {
193        let publisher = vec![
194            tag("classification", "secret"),
195            tag("releasability", "nato"),
196        ];
197        let subscriber = vec![tag("classification", "secret")];
198        assert!(BuiltinDataTaggingPlugin::tags_match(
199            &publisher,
200            &subscriber
201        ));
202    }
203
204    #[test]
205    fn match_full_set_passes() {
206        let publisher = vec![
207            tag("classification", "secret"),
208            tag("releasability", "nato"),
209        ];
210        let subscriber = publisher.clone();
211        assert!(BuiltinDataTaggingPlugin::tags_match(
212            &publisher,
213            &subscriber
214        ));
215    }
216
217    #[test]
218    fn match_missing_required_tag_rejects() {
219        let publisher = vec![tag("classification", "secret")];
220        let subscriber = vec![tag("releasability", "nato")];
221        assert!(!BuiltinDataTaggingPlugin::tags_match(
222            &publisher,
223            &subscriber
224        ));
225    }
226
227    #[test]
228    fn match_value_mismatch_rejects() {
229        // Subscriber will "secret", Publisher hat "topsecret" — Reject.
230        let publisher = vec![tag("classification", "topsecret")];
231        let subscriber = vec![tag("classification", "secret")];
232        assert!(!BuiltinDataTaggingPlugin::tags_match(
233            &publisher,
234            &subscriber
235        ));
236    }
237
238    #[test]
239    fn match_unknown_subscriber_tag_rejects() {
240        // Subscriber fordert Tag-Name den Publisher gar nicht setzt.
241        let publisher = vec![tag("classification", "secret")];
242        let subscriber = vec![tag("project", "alpha")];
243        assert!(!BuiltinDataTaggingPlugin::tags_match(
244            &publisher,
245            &subscriber
246        ));
247    }
248
249    #[test]
250    fn empty_publisher_with_required_subscriber_rejects() {
251        // Publisher ohne Tags + Subscriber mit Anforderung → Reject.
252        let subscriber = vec![tag("classification", "secret")];
253        assert!(!BuiltinDataTaggingPlugin::tags_match(&[], &subscriber));
254    }
255
256    #[test]
257    fn encode_tags_uses_namespace_prefix() {
258        let tags = vec![tag("classification", "secret")];
259        let wire = BuiltinDataTaggingPlugin::encode_tags(&tags);
260        assert_eq!(wire.len(), 1);
261        assert_eq!(wire[0].name, "dds.sec.data_tags.classification");
262        assert_eq!(wire[0].value, "secret");
263    }
264
265    #[test]
266    fn decode_tags_skips_non_tag_properties() {
267        let mut list = WirePropertyList::new();
268        list.push(WireProperty::new(
269            "dds.sec.auth.plugin_class",
270            "DDS:Auth:PKI-DH:1.2",
271        ));
272        list.push(WireProperty::new(
273            "dds.sec.data_tags.classification",
274            "secret",
275        ));
276        list.push(WireProperty::new(
277            "zerodds.sec.supported_suites",
278            "AES_128_GCM",
279        ));
280        list.push(WireProperty::new("dds.sec.data_tags.releasability", "nato"));
281        let decoded = BuiltinDataTaggingPlugin::decode_tags(&list);
282        assert_eq!(
283            decoded,
284            vec![
285                tag("classification", "secret"),
286                tag("releasability", "nato"),
287            ]
288        );
289    }
290
291    #[test]
292    fn wire_roundtrip_via_property_list() {
293        // encode_tags → WirePropertyList → CDR-bytes → WirePropertyList
294        // → decode_tags muss die Tag-Liste byte-genau reproduzieren.
295        let tags = vec![
296            tag("classification", "secret"),
297            tag("releasability", "nato"),
298        ];
299        let mut list = WirePropertyList::new();
300        for w in BuiltinDataTaggingPlugin::encode_tags(&tags) {
301            list.push(w);
302        }
303        let bytes = list.encode(true).expect("encode");
304        let decoded_list = WirePropertyList::decode(&bytes, true).expect("decode");
305        let decoded_tags = BuiltinDataTaggingPlugin::decode_tags(&decoded_list);
306        assert_eq!(decoded_tags, tags);
307    }
308
309    #[test]
310    fn plugin_is_object_safe_via_dyn_trait() {
311        // Sanity-Check: via Box<dyn DataTaggingPlugin> bedienbar.
312        let mut boxed: alloc::boxed::Box<dyn DataTaggingPlugin> =
313            alloc::boxed::Box::new(BuiltinDataTaggingPlugin::new());
314        boxed.set_tags([1; 16], vec![tag("a", "b")]);
315        assert_eq!(boxed.get_tags([1; 16]), vec![tag("a", "b")]);
316        assert_eq!(boxed.plugin_class_id(), "DDS:Tagging:Builtin");
317    }
318
319    #[test]
320    fn plugin_class_id_matches_spec_format() {
321        let p = BuiltinDataTaggingPlugin::new();
322        // Spec §12.0: Class-Id-Konvention "DDS:<Service>:<Variant>".
323        assert_eq!(p.plugin_class_id(), "DDS:Tagging:Builtin");
324    }
325}