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//! Implements the [`zerodds_security::DataTaggingPlugin`] SPI as a
7//! production builtin. Tags are application-level labels
8//! (classification markers, sensitivity, etc.) that are managed per
9//! endpoint GUID, propagated over SEDP via `PID_PROPERTY_LIST`, and
10//! checked for a match on the subscriber side.
11//!
12//! # Wire path
13//!
14//! Tags are embedded as `WireProperty` entries with the namespace prefix
15//! `dds.sec.data_tags.` into the existing [`WirePropertyList`]
16//! — i.e. we reuse the `PID_PROPERTY_LIST` parameter already propagated in
17//! SPDP/SEDP, instead of introducing a new PID.
18//! This matches the Cyclone/RTI behavior, which also carries tags via
19//! `PID_PROPERTY_LIST` (Cyclone DDS Security §8 doc).
20//!
21//! # Match predicate
22//!
23//! The default predicate is a subset match:
24//! * subscriber without tags → accepts any publisher (wildcard).
25//! * subscriber with tags → every tag (name+value) must appear exactly in
26//!   the publisher tag set.
27//! * an unknown tag name on the subscriber side that does not exist at the
28//!   publisher → reject.
29//!
30//! Spec-conform and simple enough for NGVA/FACE pilot setups; complex
31//! predicates (range/regex) are covered via a custom `DataTaggingPlugin`.
32//!
33//! # Example
34//!
35//! ```no_run
36//! use zerodds_security::data_tagging::{DataTag, DataTaggingPlugin};
37//! use zerodds_security_runtime::data_tagging::BuiltinDataTaggingPlugin;
38//!
39//! let mut plugin = BuiltinDataTaggingPlugin::new();
40//! let pub_guid = [0xAA; 16];
41//! plugin.set_tags(
42//!     pub_guid,
43//!     vec![DataTag { name: "classification".into(), value: "secret".into() }],
44//! );
45//!
46//! let sub_tags = vec![DataTag { name: "classification".into(), value: "secret".into() }];
47//! assert!(BuiltinDataTaggingPlugin::tags_match(&plugin.get_tags(pub_guid), &sub_tags));
48//! ```
49//!
50//! zerodds-lint: allow no_dyn_in_safe
51//! (plugin trait object via `Box<dyn DataTaggingPlugin>`.)
52
53extern crate alloc;
54
55use alloc::collections::BTreeMap;
56use alloc::string::String;
57use alloc::vec::Vec;
58
59use zerodds_rtps::property_list::{WireProperty, WirePropertyList};
60use zerodds_security::data_tagging::{DataTag, DataTaggingPlugin};
61
62/// Property namespace for tag wire encoding. Each tag appears as
63/// a `WireProperty` with `name = TAG_PROPERTY_PREFIX + tag.name`,
64/// `value = tag.value`. Other properties (auth-class, suite-list, …)
65/// stay untouched.
66pub const TAG_PROPERTY_PREFIX: &str = "dds.sec.data_tags.";
67
68/// Builtin DataTagging plugin for spec §12.
69#[derive(Debug, Default)]
70pub struct BuiltinDataTaggingPlugin {
71    tags: BTreeMap<[u8; 16], Vec<DataTag>>,
72}
73
74impl BuiltinDataTaggingPlugin {
75    /// Constructor — the plugin starts with no registered endpoints.
76    #[must_use]
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Default subset-match predicate (see module docs).
82    #[must_use]
83    pub fn tags_match(publisher: &[DataTag], subscriber: &[DataTag]) -> bool {
84        if subscriber.is_empty() {
85            return true;
86        }
87        subscriber.iter().all(|s| publisher.iter().any(|p| p == s))
88    }
89
90    /// Encodes a tag list as a `WireProperty` sequence for inclusion
91    /// in a [`WirePropertyList`]. Tags with a duplicate name are
92    /// written stably in input order — making the `last value
93    /// wins` semantics of the PropertyList consistent.
94    #[must_use]
95    pub fn encode_tags(tags: &[DataTag]) -> Vec<WireProperty> {
96        tags.iter()
97            .map(|t| {
98                let mut name = String::with_capacity(TAG_PROPERTY_PREFIX.len() + t.name.len());
99                name.push_str(TAG_PROPERTY_PREFIX);
100                name.push_str(&t.name);
101                WireProperty::new(name, t.value.clone())
102            })
103            .collect()
104    }
105
106    /// Filters a [`WirePropertyList`] for tag properties (prefix
107    /// match) and returns the de-prefixed tag list. Other properties
108    /// are ignored.
109    #[must_use]
110    pub fn decode_tags(list: &WirePropertyList) -> Vec<DataTag> {
111        list.entries
112            .iter()
113            .filter_map(|p| {
114                p.name.strip_prefix(TAG_PROPERTY_PREFIX).map(|n| DataTag {
115                    name: n.to_string(),
116                    value: p.value.clone(),
117                })
118            })
119            .collect()
120    }
121}
122
123impl DataTaggingPlugin for BuiltinDataTaggingPlugin {
124    fn set_tags(&mut self, endpoint_guid: [u8; 16], tags: Vec<DataTag>) {
125        if tags.is_empty() {
126            self.tags.remove(&endpoint_guid);
127        } else {
128            self.tags.insert(endpoint_guid, tags);
129        }
130    }
131
132    fn get_tags(&self, endpoint_guid: [u8; 16]) -> Vec<DataTag> {
133        self.tags.get(&endpoint_guid).cloned().unwrap_or_default()
134    }
135
136    fn plugin_class_id(&self) -> &str {
137        "DDS:Tagging:Builtin"
138    }
139}
140
141// ============================================================================
142// Tests
143// ============================================================================
144
145#[cfg(test)]
146#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
147mod tests {
148    use super::*;
149
150    fn tag(name: &str, value: &str) -> DataTag {
151        DataTag {
152            name: name.into(),
153            value: value.into(),
154        }
155    }
156
157    #[test]
158    fn set_get_roundtrip() {
159        let mut p = BuiltinDataTaggingPlugin::new();
160        let g = [0xAA; 16];
161        p.set_tags(g, vec![tag("classification", "secret")]);
162        assert_eq!(p.get_tags(g), vec![tag("classification", "secret")]);
163    }
164
165    #[test]
166    fn unknown_endpoint_returns_empty() {
167        let p = BuiltinDataTaggingPlugin::new();
168        assert!(p.get_tags([0xCC; 16]).is_empty());
169    }
170
171    #[test]
172    fn set_empty_clears_existing() {
173        let mut p = BuiltinDataTaggingPlugin::new();
174        let g = [0xBB; 16];
175        p.set_tags(g, vec![tag("a", "1")]);
176        p.set_tags(g, Vec::new());
177        assert!(p.get_tags(g).is_empty());
178    }
179
180    #[test]
181    fn match_empty_subscriber_is_wildcard() {
182        // A subscriber without tags accepts any publisher — even without tags.
183        assert!(BuiltinDataTaggingPlugin::tags_match(&[], &[]));
184        assert!(BuiltinDataTaggingPlugin::tags_match(
185            &[tag("classification", "secret")],
186            &[],
187        ));
188    }
189
190    #[test]
191    fn match_subset_passes() {
192        let publisher = vec![
193            tag("classification", "secret"),
194            tag("releasability", "nato"),
195        ];
196        let subscriber = vec![tag("classification", "secret")];
197        assert!(BuiltinDataTaggingPlugin::tags_match(
198            &publisher,
199            &subscriber
200        ));
201    }
202
203    #[test]
204    fn match_full_set_passes() {
205        let publisher = vec![
206            tag("classification", "secret"),
207            tag("releasability", "nato"),
208        ];
209        let subscriber = publisher.clone();
210        assert!(BuiltinDataTaggingPlugin::tags_match(
211            &publisher,
212            &subscriber
213        ));
214    }
215
216    #[test]
217    fn match_missing_required_tag_rejects() {
218        let publisher = vec![tag("classification", "secret")];
219        let subscriber = vec![tag("releasability", "nato")];
220        assert!(!BuiltinDataTaggingPlugin::tags_match(
221            &publisher,
222            &subscriber
223        ));
224    }
225
226    #[test]
227    fn match_value_mismatch_rejects() {
228        // Subscriber will "secret", Publisher hat "topsecret" — Reject.
229        let publisher = vec![tag("classification", "topsecret")];
230        let subscriber = vec![tag("classification", "secret")];
231        assert!(!BuiltinDataTaggingPlugin::tags_match(
232            &publisher,
233            &subscriber
234        ));
235    }
236
237    #[test]
238    fn match_unknown_subscriber_tag_rejects() {
239        // Subscriber requires a tag name the publisher does not set at all.
240        let publisher = vec![tag("classification", "secret")];
241        let subscriber = vec![tag("project", "alpha")];
242        assert!(!BuiltinDataTaggingPlugin::tags_match(
243            &publisher,
244            &subscriber
245        ));
246    }
247
248    #[test]
249    fn empty_publisher_with_required_subscriber_rejects() {
250        // Publisher without tags + subscriber with a requirement → reject.
251        let subscriber = vec![tag("classification", "secret")];
252        assert!(!BuiltinDataTaggingPlugin::tags_match(&[], &subscriber));
253    }
254
255    #[test]
256    fn encode_tags_uses_namespace_prefix() {
257        let tags = vec![tag("classification", "secret")];
258        let wire = BuiltinDataTaggingPlugin::encode_tags(&tags);
259        assert_eq!(wire.len(), 1);
260        assert_eq!(wire[0].name, "dds.sec.data_tags.classification");
261        assert_eq!(wire[0].value, "secret");
262    }
263
264    #[test]
265    fn decode_tags_skips_non_tag_properties() {
266        let mut list = WirePropertyList::new();
267        list.push(WireProperty::new(
268            "dds.sec.auth.plugin_class",
269            "DDS:Auth:PKI-DH:1.2",
270        ));
271        list.push(WireProperty::new(
272            "dds.sec.data_tags.classification",
273            "secret",
274        ));
275        list.push(WireProperty::new(
276            "zerodds.sec.supported_suites",
277            "AES_128_GCM",
278        ));
279        list.push(WireProperty::new("dds.sec.data_tags.releasability", "nato"));
280        let decoded = BuiltinDataTaggingPlugin::decode_tags(&list);
281        assert_eq!(
282            decoded,
283            vec![
284                tag("classification", "secret"),
285                tag("releasability", "nato"),
286            ]
287        );
288    }
289
290    #[test]
291    fn wire_roundtrip_via_property_list() {
292        // encode_tags → WirePropertyList → CDR-bytes → WirePropertyList
293        // → decode_tags must reproduce the tag list byte-exactly.
294        let tags = vec![
295            tag("classification", "secret"),
296            tag("releasability", "nato"),
297        ];
298        let mut list = WirePropertyList::new();
299        for w in BuiltinDataTaggingPlugin::encode_tags(&tags) {
300            list.push(w);
301        }
302        let bytes = list.encode(true).expect("encode");
303        let decoded_list = WirePropertyList::decode(&bytes, true).expect("decode");
304        let decoded_tags = BuiltinDataTaggingPlugin::decode_tags(&decoded_list);
305        assert_eq!(decoded_tags, tags);
306    }
307
308    #[test]
309    fn plugin_is_object_safe_via_dyn_trait() {
310        // Sanity-Check: via Box<dyn DataTaggingPlugin> bedienbar.
311        let mut boxed: alloc::boxed::Box<dyn DataTaggingPlugin> =
312            alloc::boxed::Box::new(BuiltinDataTaggingPlugin::new());
313        boxed.set_tags([1; 16], vec![tag("a", "b")]);
314        assert_eq!(boxed.get_tags([1; 16]), vec![tag("a", "b")]);
315        assert_eq!(boxed.plugin_class_id(), "DDS:Tagging:Builtin");
316    }
317
318    #[test]
319    fn plugin_class_id_matches_spec_format() {
320        let p = BuiltinDataTaggingPlugin::new();
321        // Spec §12.0: class-id convention "DDS:<Service>:<Variant>".
322        assert_eq!(p.plugin_class_id(), "DDS:Tagging:Builtin");
323    }
324}