Skip to main content

zerodds_security_runtime/
endpoint.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Endpoint-level protection abstraction.
5//!
6//! Bridge between the wire type [`EndpointSecurityInfo`] and the
7//! policy level [`ProtectionLevel`]. Holds the match logic for a
8//! writer/reader pair: per endpoint the wire carries a 2x u32
9//! bitmask block, from which the policy engine decides whether a match
10//! occurs and with which resulting protection level.
11//!
12//! # DoD matching matrix (plan §stage 3)
13//!
14//! | Writer         | Reader          | Result                         |
15//! |----------------|-----------------|--------------------------------|
16//! | `Encrypt`      | no caps         | match rejected                 |
17//! | `Sign`         | `Encrypt`       | match, end level = `Encrypt`   |
18//! | `None`         | `None`          | match, end level = `None`      |
19//! | `Encrypt`      | `Encrypt`       | match, end level = `Encrypt`   |
20//!
21//! "Strongest value wins": `max(writer, reader)`. If one of the
22//! endpoints supplies no `EndpointSecurityInfo` (legacy peer), the
23//! pair is only accepted if **both** effectively run `None`.
24
25use zerodds_rtps::endpoint_security_info::{EndpointSecurityInfo, attrs, plugin_attrs};
26
27use crate::policy::ProtectionLevel;
28
29/// Policy view of an endpoint: which protection level this writer/reader
30/// requires/offers.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct EndpointProtection {
33    /// Required/offered protection level.
34    pub level: ProtectionLevel,
35}
36
37impl EndpointProtection {
38    /// Shorthand: plaintext endpoint (legacy or deliberately `None`).
39    pub const PLAIN: Self = Self {
40        level: ProtectionLevel::None,
41    };
42
43    /// Builder.
44    #[must_use]
45    pub const fn new(level: ProtectionLevel) -> Self {
46        Self { level }
47    }
48
49    /// Derivation from an [`EndpointSecurityInfo`]:
50    /// * payload-encrypted → `Encrypt`
51    /// * submessage-encrypted → `Encrypt`
52    /// * submessage/payload protected without the plugin-encrypt flag → `Sign`
53    /// * no protection bits → `None`
54    ///
55    /// `None` as the argument (legacy endpoint) → `Self::PLAIN`.
56    #[must_use]
57    pub fn from_info(info: Option<&EndpointSecurityInfo>) -> Self {
58        let Some(info) = info else {
59            return Self::PLAIN;
60        };
61        if !info.is_valid() {
62            // Spec §7.4.1.5: with IS_VALID missing the bits are
63            // not interpretable. Treat as legacy.
64            return Self::PLAIN;
65        }
66        if info.is_payload_encrypted() || info.is_submessage_encrypted() {
67            return Self::new(ProtectionLevel::Encrypt);
68        }
69        if info.is_submessage_protected() || info.is_payload_protected() {
70            return Self::new(ProtectionLevel::Sign);
71        }
72        Self::PLAIN
73    }
74
75    /// Serialization back into [`EndpointSecurityInfo`] — for the
76    /// SEDP announce of our own endpoint.
77    #[must_use]
78    pub fn to_info(self) -> EndpointSecurityInfo {
79        let mut endpoint = attrs::IS_VALID;
80        let mut plugin = plugin_attrs::IS_VALID;
81        match self.level {
82            ProtectionLevel::None => {}
83            ProtectionLevel::Sign => {
84                endpoint |= attrs::IS_SUBMESSAGE_PROTECTED;
85            }
86            ProtectionLevel::Encrypt => {
87                endpoint |= attrs::IS_SUBMESSAGE_PROTECTED | attrs::IS_PAYLOAD_PROTECTED;
88                plugin |=
89                    plugin_attrs::IS_SUBMESSAGE_ENCRYPTED | plugin_attrs::IS_PAYLOAD_ENCRYPTED;
90            }
91        }
92        EndpointSecurityInfo {
93            endpoint_security_attributes: endpoint,
94            plugin_endpoint_security_attributes: plugin,
95        }
96    }
97}
98
99/// Result of a writer/reader match check.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum EndpointMatch {
102    /// Match accepted, at this effective protection level
103    /// (`max(writer, reader)`).
104    Accept(ProtectionLevel),
105    /// Match rejected — the peer cannot supply the required protection.
106    Reject(MatchRejectReason),
107}
108
109/// Why the matching is a reject.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum MatchRejectReason {
112    /// One of the peers has a protection requirement but supplied no
113    /// `EndpointSecurityInfo` → interpreted as legacy —
114    /// the match is only admissible with `None` on both sides.
115    LegacyPeerVsProtection,
116}
117
118/// Match writer ↔ reader. From the two [`EndpointProtection`]
119/// values it is determined:
120/// * Is the pair compatible?
121/// * At which level is communication done?
122///
123/// Rule (plan §stage 3 DoD):
124/// * writer `Encrypt`, reader `None` without info → `Reject`
125/// * writer `None` without info, reader `Encrypt` → `Reject`
126/// * writer `Sign`, reader `Encrypt` → `Accept(Encrypt)` (stronger wins)
127/// * everything else → `Accept(max(w, r))`
128#[must_use]
129pub fn match_endpoints(
130    writer: &EndpointProtection,
131    reader: &EndpointProtection,
132    writer_has_info: bool,
133    reader_has_info: bool,
134) -> EndpointMatch {
135    // If one side wants protection and the other is a legacy
136    // endpoint (no EndpointSecurityInfo), that is a reject.
137    let writer_wants_protection = !matches!(writer.level, ProtectionLevel::None);
138    let reader_wants_protection = !matches!(reader.level, ProtectionLevel::None);
139    if writer_wants_protection && !reader_has_info {
140        return EndpointMatch::Reject(MatchRejectReason::LegacyPeerVsProtection);
141    }
142    if reader_wants_protection && !writer_has_info {
143        return EndpointMatch::Reject(MatchRejectReason::LegacyPeerVsProtection);
144    }
145    EndpointMatch::Accept(writer.level.stronger(reader.level))
146}
147
148// ============================================================================
149// Tests
150// ============================================================================
151
152#[cfg(test)]
153#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
154mod tests {
155    use super::*;
156
157    // ---- EndpointProtection::from_info ----
158
159    #[test]
160    fn from_info_none_is_plain() {
161        assert_eq!(
162            EndpointProtection::from_info(None),
163            EndpointProtection::PLAIN
164        );
165    }
166
167    #[test]
168    fn from_info_invalid_valid_bit_is_plain() {
169        // Flags set, but no IS_VALID -> spec forbids
170        // interpretation -> legacy.
171        let info = EndpointSecurityInfo {
172            endpoint_security_attributes: attrs::IS_SUBMESSAGE_PROTECTED,
173            plugin_endpoint_security_attributes: plugin_attrs::IS_SUBMESSAGE_ENCRYPTED,
174        };
175        assert_eq!(
176            EndpointProtection::from_info(Some(&info)),
177            EndpointProtection::PLAIN
178        );
179    }
180
181    #[test]
182    fn from_info_plain_legacy_is_plain() {
183        let info = EndpointSecurityInfo::plain();
184        assert_eq!(
185            EndpointProtection::from_info(Some(&info)),
186            EndpointProtection::PLAIN
187        );
188    }
189
190    #[test]
191    fn from_info_submessage_protected_without_encrypt_is_sign() {
192        let info = EndpointSecurityInfo {
193            endpoint_security_attributes: attrs::IS_VALID | attrs::IS_SUBMESSAGE_PROTECTED,
194            plugin_endpoint_security_attributes: plugin_attrs::IS_VALID,
195        };
196        assert_eq!(
197            EndpointProtection::from_info(Some(&info)).level,
198            ProtectionLevel::Sign
199        );
200    }
201
202    #[test]
203    fn from_info_submessage_encrypted_is_encrypt() {
204        let info = EndpointSecurityInfo {
205            endpoint_security_attributes: attrs::IS_VALID | attrs::IS_SUBMESSAGE_PROTECTED,
206            plugin_endpoint_security_attributes: plugin_attrs::IS_VALID
207                | plugin_attrs::IS_SUBMESSAGE_ENCRYPTED,
208        };
209        assert_eq!(
210            EndpointProtection::from_info(Some(&info)).level,
211            ProtectionLevel::Encrypt
212        );
213    }
214
215    #[test]
216    fn from_info_payload_encrypted_is_encrypt() {
217        let info = EndpointSecurityInfo {
218            endpoint_security_attributes: attrs::IS_VALID | attrs::IS_PAYLOAD_PROTECTED,
219            plugin_endpoint_security_attributes: plugin_attrs::IS_VALID
220                | plugin_attrs::IS_PAYLOAD_ENCRYPTED,
221        };
222        assert_eq!(
223            EndpointProtection::from_info(Some(&info)).level,
224            ProtectionLevel::Encrypt
225        );
226    }
227
228    // ---- EndpointProtection::to_info ----
229
230    #[test]
231    fn to_info_none_sets_only_valid_bits() {
232        let info = EndpointProtection::PLAIN.to_info();
233        assert!(info.is_valid());
234        assert!(!info.is_submessage_protected());
235        assert!(!info.is_payload_protected());
236        assert!(!info.is_submessage_encrypted());
237    }
238
239    #[test]
240    fn to_info_sign_sets_submessage_protected_without_encryption() {
241        let info = EndpointProtection::new(ProtectionLevel::Sign).to_info();
242        assert!(info.is_valid());
243        assert!(info.is_submessage_protected());
244        assert!(!info.is_submessage_encrypted());
245        assert!(!info.is_payload_encrypted());
246    }
247
248    #[test]
249    fn to_info_encrypt_sets_both_protection_and_encryption() {
250        let info = EndpointProtection::new(ProtectionLevel::Encrypt).to_info();
251        assert!(info.is_valid());
252        assert!(info.is_submessage_protected());
253        assert!(info.is_payload_protected());
254        assert!(info.is_submessage_encrypted());
255        assert!(info.is_payload_encrypted());
256    }
257
258    #[test]
259    fn to_info_from_info_roundtrip_for_all_levels() {
260        for lvl in [
261            ProtectionLevel::None,
262            ProtectionLevel::Sign,
263            ProtectionLevel::Encrypt,
264        ] {
265            let ep = EndpointProtection::new(lvl);
266            let info = ep.to_info();
267            let back = EndpointProtection::from_info(Some(&info));
268            assert_eq!(back, ep, "roundtrip fails for {lvl:?}");
269        }
270    }
271
272    // ---- match_endpoints — DoD matrix from plan §stage 3 ----
273
274    #[test]
275    fn dod_writer_encrypt_reader_no_plugin_is_reject() {
276        let w = EndpointProtection::new(ProtectionLevel::Encrypt);
277        let r = EndpointProtection::PLAIN;
278        let result = match_endpoints(
279            &w, &r, /*writer_has_info=*/ true, /*reader_has_info=*/ false,
280        );
281        assert_eq!(
282            result,
283            EndpointMatch::Reject(MatchRejectReason::LegacyPeerVsProtection)
284        );
285    }
286
287    #[test]
288    fn dod_writer_sign_reader_encrypt_accepts_with_encrypt() {
289        let w = EndpointProtection::new(ProtectionLevel::Sign);
290        let r = EndpointProtection::new(ProtectionLevel::Encrypt);
291        let result = match_endpoints(&w, &r, true, true);
292        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::Encrypt));
293    }
294
295    #[test]
296    fn writer_none_reader_none_both_info_accepts_none() {
297        let w = EndpointProtection::PLAIN;
298        let r = EndpointProtection::PLAIN;
299        let result = match_endpoints(&w, &r, true, true);
300        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::None));
301    }
302
303    #[test]
304    fn writer_none_reader_none_both_legacy_accepts_none() {
305        // Two legacy peers without a security PID — legacy ↔ legacy is ok.
306        let w = EndpointProtection::PLAIN;
307        let r = EndpointProtection::PLAIN;
308        let result = match_endpoints(&w, &r, false, false);
309        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::None));
310    }
311
312    #[test]
313    fn writer_encrypt_reader_encrypt_accepts_encrypt() {
314        let w = EndpointProtection::new(ProtectionLevel::Encrypt);
315        let r = EndpointProtection::new(ProtectionLevel::Encrypt);
316        let result = match_endpoints(&w, &r, true, true);
317        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::Encrypt));
318    }
319
320    #[test]
321    fn writer_plain_reader_encrypt_rejects_if_writer_legacy() {
322        // Writer is legacy (no security info), reader wants Encrypt → reject.
323        let w = EndpointProtection::PLAIN;
324        let r = EndpointProtection::new(ProtectionLevel::Encrypt);
325        let result = match_endpoints(&w, &r, /*writer_has_info=*/ false, true);
326        assert_eq!(
327            result,
328            EndpointMatch::Reject(MatchRejectReason::LegacyPeerVsProtection)
329        );
330    }
331
332    #[test]
333    fn writer_encrypt_reader_sign_accepts_with_encrypt() {
334        // Symmetric case to the DoD example: reader offers SIGN,
335        // writer ENCRYPT → stronger wins = ENCRYPT.
336        let w = EndpointProtection::new(ProtectionLevel::Encrypt);
337        let r = EndpointProtection::new(ProtectionLevel::Sign);
338        let result = match_endpoints(&w, &r, true, true);
339        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::Encrypt));
340    }
341
342    #[test]
343    fn writer_sign_reader_sign_accepts_with_sign() {
344        let w = EndpointProtection::new(ProtectionLevel::Sign);
345        let r = EndpointProtection::new(ProtectionLevel::Sign);
346        let result = match_endpoints(&w, &r, true, true);
347        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::Sign));
348    }
349
350    #[test]
351    fn writer_none_with_info_reader_sign_rejects_only_if_writer_cant() {
352        // Writer has SecurityInfo but level=None (explicitly plaintext);
353        // reader wants SIGN → from the reader's perspective that is not satisfiable
354        // — in the current version we accept the match because both
355        // have SecurityInfo. The DoD allows "stronger wins".
356        let w = EndpointProtection::PLAIN;
357        let r = EndpointProtection::new(ProtectionLevel::Sign);
358        let result = match_endpoints(&w, &r, true, true);
359        assert_eq!(result, EndpointMatch::Accept(ProtectionLevel::Sign));
360    }
361}