zerodds_security_runtime/policy.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Heterogeneous security — `PolicyEngine` trait and data types
5//!.
6//!
7//! This layer is the abstraction over the governance-XML stack.
8//! The v1.4 state ([`crate::SharedSecurityGate`]) decides **one**
9//! protection level per participant. System-of-systems deployments
10//! (vehicle, tactical, edge) need finer granularity on the triple
11//! axis `(peer, topic, interface)`.
12//!
13//! [`PolicyEngine`] encapsulates this decision:
14//! * [`PolicyEngine::outbound_decision`] is called per matched reader
15//! before a wire packet is written.
16//! * [`PolicyEngine::inbound_decision`] is called per incoming datagram.
17//! * [`PolicyEngine::accept_peer`] is the admission check during
18//! SEDP matching.
19//!
20//! The default implementation ([`crate::GovernancePolicyEngine`], in
21//! stage 1c) mirrors the current `SharedSecurityGate` semantics 1:1.
22//! Users can plug in their own `PolicyEngine` impls, e.g. to derive
23//! decisions from an external policy server or a vehicle-network
24//! certification database.
25//!
26//! See `docs/architecture/08_heterogeneous_security.md` §3.1.
27
28use alloc::string::String;
29use alloc::vec::Vec;
30
31use core::net::IpAddr;
32
33use zerodds_security_crypto::Suite;
34use zerodds_security_permissions::ProtectionKind;
35
36use crate::caps::PeerCapabilities;
37use crate::shared::PeerKey;
38
39// ============================================================================
40// Base types
41// ============================================================================
42
43/// Abstract protection level for the policy layer.
44///
45/// Unlike [`ProtectionKind`] (XML parser type, 5 variants
46/// incl. origin authentication), `ProtectionLevel` carries only the 3
47/// base classes. Policy decisions compare/order these
48/// levels — the origin-auth refinement follows from
49/// [`PolicyDecision::suite`] (receiver-specific MACs, RC1).
50///
51/// The order `None < Sign < Encrypt` is relevant for the "strongest
52/// value wins" matching in stage 3 (SEDP endpoint caps)
53/// — `Ord`/`PartialOrd` are defined accordingly.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
55pub enum ProtectionLevel {
56 /// No protection — plaintext RTPS on the wire.
57 #[default]
58 None,
59 /// Integrity protection (HMAC/AEAD tag), payload stays readable.
60 Sign,
61 /// Integrity + confidentiality (AEAD ciphertext).
62 Encrypt,
63}
64
65impl ProtectionLevel {
66 /// Mapping from the governance-XML [`ProtectionKind`]. Origin-auth
67 /// variants collapse to their base class; the origin-auth
68 /// property is transported via [`PolicyDecision::suite`] +
69 /// receiver-specific MAC encoding.
70 #[must_use]
71 pub fn from_protection_kind(kind: ProtectionKind) -> Self {
72 match kind {
73 ProtectionKind::None => Self::None,
74 ProtectionKind::Sign | ProtectionKind::SignWithOriginAuthentication => Self::Sign,
75 ProtectionKind::Encrypt | ProtectionKind::EncryptWithOriginAuthentication => {
76 Self::Encrypt
77 }
78 }
79 }
80
81 /// Reverse mapping to [`ProtectionKind`] without origin-auth refinement.
82 #[must_use]
83 pub fn to_protection_kind(self) -> ProtectionKind {
84 match self {
85 Self::None => ProtectionKind::None,
86 Self::Sign => ProtectionKind::Sign,
87 Self::Encrypt => ProtectionKind::Encrypt,
88 }
89 }
90
91 /// Picks the stronger of two levels (e.g. writer
92 /// vs. reader offer).
93 #[must_use]
94 pub fn stronger(self, other: Self) -> Self {
95 if self >= other { self } else { other }
96 }
97}
98
99/// Crypto-suite hint for the policy decision.
100///
101/// `SuiteHint` is a **wish** of the policy engine — the crypto
102/// plugin may ignore it if it does not support the
103/// algorithm. The concrete [`Suite`] enum (`security-crypto`)
104/// is the plugin-internal type; this indirection allows future
105/// suites (ChaCha20-Poly1305, AES-CCM) without a breaking change to the
106/// policy API.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
108pub enum SuiteHint {
109 /// AES-128-GCM — default suite v1.4.
110 Aes128Gcm,
111 /// AES-256-GCM — for long-term confidentiality / compliance.
112 Aes256Gcm,
113 /// HMAC-SHA256 auth-only (no confidentiality, SIGN level).
114 HmacSha256,
115}
116
117impl SuiteHint {
118 /// Mapping to the plugin-internal [`Suite`].
119 #[must_use]
120 pub fn to_suite(self) -> Suite {
121 match self {
122 Self::Aes128Gcm => Suite::Aes128Gcm,
123 Self::Aes256Gcm => Suite::Aes256Gcm,
124 Self::HmacSha256 => Suite::HmacSha256,
125 }
126 }
127
128 /// Reverse mapping from [`Suite`].
129 #[must_use]
130 pub fn from_suite(suite: Suite) -> Self {
131 match suite {
132 Suite::Aes128Gcm => Self::Aes128Gcm,
133 Suite::Aes256Gcm => Self::Aes256Gcm,
134 // AES-256-GMAC is auth-only (SIGN) — for the capability advertisement
135 // map it to the SIGN hint; the real key suite is set directly via
136 // set_local_protection_suites, not via this hint.
137 Suite::HmacSha256 | Suite::Aes256Gmac => Self::HmacSha256,
138 }
139 }
140
141 /// Returns the natural protection level of this suite:
142 /// AEAD suites → `Encrypt`, HMAC → `Sign`.
143 #[must_use]
144 pub fn protection_level(self) -> ProtectionLevel {
145 match self {
146 Self::Aes128Gcm | Self::Aes256Gcm => ProtectionLevel::Encrypt,
147 Self::HmacSha256 => ProtectionLevel::Sign,
148 }
149 }
150}
151
152// ============================================================================
153// NetInterface
154// ============================================================================
155
156/// CIDR-like IPv4/IPv6 range, interpreted inclusively.
157///
158/// A minimal self-build (no `ipnet` dep, to keep the safety footprint
159/// small). Prefix length in host bits: IPv4 up to 32, IPv6
160/// up to 128. Parsing (e.g. from `"10.0.0.0/24"`) arrives in RC1
161/// (governance XML); here the struct form suffices.
162#[derive(Debug, Clone, PartialEq, Eq, Hash)]
163pub struct IpRange {
164 /// Base address of the range (host bits are ignored).
165 pub base: IpAddr,
166 /// Prefix length in bits. Must be `<= 32` for v4, `<= 128` for v6.
167 pub prefix_len: u8,
168}
169
170impl IpRange {
171 /// Checks whether `addr` lies in the range. Mixed families
172 /// (v4 in a v6 range) are **not** a match.
173 #[must_use]
174 pub fn contains(&self, addr: &IpAddr) -> bool {
175 match (self.base, addr) {
176 (IpAddr::V4(base), IpAddr::V4(a)) => {
177 if self.prefix_len > 32 {
178 return false;
179 }
180 let shift = 32 - u32::from(self.prefix_len);
181 let base_u = u32::from(base);
182 let a_u = u32::from(*a);
183 if self.prefix_len == 0 {
184 true
185 } else {
186 (base_u >> shift) == (a_u >> shift)
187 }
188 }
189 (IpAddr::V6(base), IpAddr::V6(a)) => {
190 if self.prefix_len > 128 {
191 return false;
192 }
193 let base_u = u128::from(base);
194 let a_u = u128::from(*a);
195 if self.prefix_len == 0 {
196 true
197 } else {
198 let shift = 128 - u32::from(self.prefix_len);
199 (base_u >> shift) == (a_u >> shift)
200 }
201 }
202 _ => false,
203 }
204 }
205}
206
207/// Classification of a network interface for the policy decision.
208///
209/// The engine can decide differently based on it:
210/// * `Loopback` + `LocalHost` → often `ProtectionLevel::None` (bytes
211/// do not leave the host).
212/// * `LocalSubnet` → management networks with `Sign` instead of `Encrypt`.
213/// * `Wan` → the most restrictive policy.
214/// * `Named` → user-configured classification (e.g. `tun0`
215/// as VPN-protected, `can0-gw` as a vehicle-bus gateway).
216#[derive(Debug, Clone, PartialEq, Eq, Hash)]
217pub enum NetInterface {
218 /// 127.0.0.0/8 or `::1`.
219 Loopback,
220 /// Host-local transport outside loopback (shared memory,
221 /// Unix domain socket) — bytes do not leave the host kernel.
222 LocalHost,
223 /// Address in a configured private range (e.g.
224 /// `10.0.0.0/24`, `192.168.0.0/16`).
225 LocalSubnet(IpRange),
226 /// Everything else — public IP, unknown interface.
227 Wan,
228 /// User-configured interface class by name.
229 Named(String),
230}
231
232/// Runtime configuration of the interface classifier.
233///
234/// Built by the user at participant start and passed to the
235/// [`PolicyEngine`]. Empty configuration → every non-
236/// loopback address lands in [`NetInterface::Wan`].
237///
238/// The `named` list allows patterns like "all addresses in the tun0 subnet
239/// should become `NetInterface::Named("vpn".into())`". The list
240/// is processed in order — first match wins.
241#[derive(Debug, Clone, Default)]
242pub struct InterfaceConfig {
243 /// Private subnets classified as [`NetInterface::LocalSubnet`].
244 pub local_subnets: Vec<IpRange>,
245 /// Named-interface mappings: `(range, name)` — first match
246 /// yields [`NetInterface::Named`].
247 pub named: Vec<(IpRange, String)>,
248}
249
250/// Classifies an IP address into the `NetInterface` taxonomy.
251///
252/// Order:
253/// 1. Loopback (127/8, `::1`).
254/// 2. Named matches from `config.named` (first match wins).
255/// 3. Local-subnet matches from `config.local_subnets`.
256/// 4. Fallback `Wan`.
257#[must_use]
258pub fn classify_interface(addr: &IpAddr, config: &InterfaceConfig) -> NetInterface {
259 if addr.is_loopback() {
260 return NetInterface::Loopback;
261 }
262 for (range, name) in &config.named {
263 if range.contains(addr) {
264 return NetInterface::Named(name.clone());
265 }
266 }
267 for range in &config.local_subnets {
268 if range.contains(addr) {
269 return NetInterface::LocalSubnet(range.clone());
270 }
271 }
272 NetInterface::Wan
273}
274
275// ============================================================================
276// Policy decision
277// ============================================================================
278
279/// Decision of the [`PolicyEngine`] for a concrete
280/// packet/peer/interface triple.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct PolicyDecision {
283 /// Required protection level.
284 pub protection: ProtectionLevel,
285 /// Desired crypto suite. `None` for `ProtectionLevel::None`
286 /// or when the engine leaves the choice to the plugin.
287 pub suite: Option<SuiteHint>,
288 /// Hard drop — the packet is not delivered, the peer not
289 /// accepted. If `true`, `protection`/`suite` are irrelevant.
290 pub drop: bool,
291}
292
293impl PolicyDecision {
294 /// Shorthand: "plaintext accepted/expected".
295 pub const PLAIN: Self = Self {
296 protection: ProtectionLevel::None,
297 suite: None,
298 drop: false,
299 };
300
301 /// Shorthand: "hard drop".
302 pub const DROP: Self = Self {
303 protection: ProtectionLevel::None,
304 suite: None,
305 drop: true,
306 };
307
308 /// Builds a decision from protection + suite. For
309 /// `ProtectionLevel::None`, `suite` is forced to `None`.
310 #[must_use]
311 pub fn with(protection: ProtectionLevel, suite: Option<SuiteHint>) -> Self {
312 let suite = if matches!(protection, ProtectionLevel::None) {
313 None
314 } else {
315 suite
316 };
317 Self {
318 protection,
319 suite,
320 drop: false,
321 }
322 }
323}
324
325// ============================================================================
326// Context objects
327// ============================================================================
328
329/// Outbound decision context: a writer sends to a peer.
330#[derive(Debug)]
331pub struct OutboundCtx<'a> {
332 /// Domain the writer lives in.
333 pub domain_id: u32,
334 /// Topic name (for governance `topic_rule` matching).
335 pub topic: &'a str,
336 /// Partition names (for the permissions check).
337 pub partition: &'a [String],
338 /// Class of the interface the packet goes out on.
339 pub interface: &'a NetInterface,
340 /// GuidPrefix of the remote peer.
341 pub remote_peer: &'a PeerKey,
342 /// Capability snapshot of the remote peer (from SPDP/SEDP).
343 pub remote_caps: &'a PeerCapabilities,
344}
345
346/// Inbound decision context: a datagram has come in.
347#[derive(Debug)]
348pub struct InboundCtx<'a> {
349 /// Domain the receiver lives in.
350 pub domain_id: u32,
351 /// GuidPrefix of the sender (from RTPS header bytes 8..20).
352 pub source_peer: &'a PeerKey,
353 /// Class of the receive interface.
354 pub source_iface: &'a NetInterface,
355 /// Capability snapshot of the sender. `None` if the peer has never
356 /// sent an SPDP announce (legacy vendor or
357 /// pre-discovery).
358 pub source_caps: Option<&'a PeerCapabilities>,
359 /// `true` if the packet begins with `SRTPS_PREFIX` (i.e. according to the
360 /// wire format it is already protected).
361 pub is_sec_prefixed: bool,
362}
363
364// ============================================================================
365// Trait
366// ============================================================================
367
368/// Policy engine: decides the protection level for a concrete `(peer, topic,
369/// interface)` triple.
370///
371/// # Safety classification
372///
373/// The trait is `Send + Sync` so it can be used via `Arc<dyn PolicyEngine>` in
374/// a multi-thread runtime. This triggers
375/// `zerodds-lint: allow no_dyn_in_safe` (documented in
376/// `08_heterogeneous_security.md` §7).
377///
378/// # Default contract
379///
380/// * Implementations must be **deterministic**: same
381/// context inputs → same decision. No randomness, no
382/// time-dependent branches (otherwise replay attacks are possible).
383/// * `accept_peer` may return `false` if the peer does not
384/// meet the minimal requirements (e.g. a missing `auth_plugin_class`
385/// for a domain with `allow_unauthenticated_participants=false`).
386/// * `outbound_decision`/`inbound_decision` must **not**
387/// block — they run in the hot path.
388pub trait PolicyEngine: Send + Sync {
389 /// Outbound path: which protection level should the wire packet have?
390 fn outbound_decision(&self, ctx: OutboundCtx<'_>) -> PolicyDecision;
391
392 /// Inbound path: accept / drop / decrypt the packet?
393 fn inbound_decision(&self, ctx: InboundCtx<'_>) -> PolicyDecision;
394
395 /// SEDP admission: is this peer (according to its capabilities)
396 /// fundamentally acceptable for a match?
397 fn accept_peer(&self, caps: &PeerCapabilities) -> bool;
398}
399
400// ============================================================================
401// Tests
402// ============================================================================
403
404#[cfg(test)]
405#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
406mod tests {
407 use super::*;
408 use alloc::string::ToString;
409 use core::net::{Ipv4Addr, Ipv6Addr};
410
411 // ---- ProtectionLevel ----
412
413 #[test]
414 fn protection_level_orders_none_sign_encrypt() {
415 assert!(ProtectionLevel::None < ProtectionLevel::Sign);
416 assert!(ProtectionLevel::Sign < ProtectionLevel::Encrypt);
417 }
418
419 #[test]
420 fn protection_level_stronger_picks_max() {
421 assert_eq!(
422 ProtectionLevel::Sign.stronger(ProtectionLevel::Encrypt),
423 ProtectionLevel::Encrypt
424 );
425 assert_eq!(
426 ProtectionLevel::Encrypt.stronger(ProtectionLevel::None),
427 ProtectionLevel::Encrypt
428 );
429 assert_eq!(
430 ProtectionLevel::None.stronger(ProtectionLevel::None),
431 ProtectionLevel::None
432 );
433 }
434
435 #[test]
436 fn protection_level_from_kind_collapses_origin_auth() {
437 assert_eq!(
438 ProtectionLevel::from_protection_kind(ProtectionKind::None),
439 ProtectionLevel::None
440 );
441 assert_eq!(
442 ProtectionLevel::from_protection_kind(ProtectionKind::Sign),
443 ProtectionLevel::Sign
444 );
445 assert_eq!(
446 ProtectionLevel::from_protection_kind(ProtectionKind::SignWithOriginAuthentication),
447 ProtectionLevel::Sign
448 );
449 assert_eq!(
450 ProtectionLevel::from_protection_kind(ProtectionKind::Encrypt),
451 ProtectionLevel::Encrypt
452 );
453 assert_eq!(
454 ProtectionLevel::from_protection_kind(ProtectionKind::EncryptWithOriginAuthentication),
455 ProtectionLevel::Encrypt
456 );
457 }
458
459 #[test]
460 fn protection_level_to_kind_roundtrip_without_origin_auth() {
461 for lvl in [
462 ProtectionLevel::None,
463 ProtectionLevel::Sign,
464 ProtectionLevel::Encrypt,
465 ] {
466 let kind = lvl.to_protection_kind();
467 assert_eq!(ProtectionLevel::from_protection_kind(kind), lvl);
468 }
469 }
470
471 #[test]
472 fn protection_level_default_is_none() {
473 assert_eq!(ProtectionLevel::default(), ProtectionLevel::None);
474 }
475
476 // ---- SuiteHint ----
477
478 #[test]
479 fn suite_hint_roundtrip_suite() {
480 for s in [Suite::Aes128Gcm, Suite::Aes256Gcm, Suite::HmacSha256] {
481 assert_eq!(SuiteHint::from_suite(s).to_suite(), s);
482 }
483 }
484
485 #[test]
486 fn suite_hint_protection_level_matches_semantics() {
487 assert_eq!(
488 SuiteHint::Aes128Gcm.protection_level(),
489 ProtectionLevel::Encrypt
490 );
491 assert_eq!(
492 SuiteHint::Aes256Gcm.protection_level(),
493 ProtectionLevel::Encrypt
494 );
495 assert_eq!(
496 SuiteHint::HmacSha256.protection_level(),
497 ProtectionLevel::Sign
498 );
499 }
500
501 // ---- IpRange ----
502
503 fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
504 IpAddr::V4(Ipv4Addr::new(a, b, c, d))
505 }
506
507 #[test]
508 fn ip_range_v4_match_inside_prefix() {
509 let r = IpRange {
510 base: v4(10, 0, 0, 0),
511 prefix_len: 24,
512 };
513 assert!(r.contains(&v4(10, 0, 0, 1)));
514 assert!(r.contains(&v4(10, 0, 0, 255)));
515 assert!(!r.contains(&v4(10, 0, 1, 0)));
516 assert!(!r.contains(&v4(11, 0, 0, 0)));
517 }
518
519 #[test]
520 fn ip_range_v4_prefix_zero_matches_all_v4() {
521 let r = IpRange {
522 base: v4(0, 0, 0, 0),
523 prefix_len: 0,
524 };
525 assert!(r.contains(&v4(1, 2, 3, 4)));
526 assert!(r.contains(&v4(255, 255, 255, 255)));
527 }
528
529 #[test]
530 fn ip_range_v4_prefix_32_is_exact_host() {
531 let r = IpRange {
532 base: v4(192, 168, 1, 5),
533 prefix_len: 32,
534 };
535 assert!(r.contains(&v4(192, 168, 1, 5)));
536 assert!(!r.contains(&v4(192, 168, 1, 6)));
537 }
538
539 #[test]
540 fn ip_range_v4_out_of_range_prefix_never_matches() {
541 let r = IpRange {
542 base: v4(10, 0, 0, 0),
543 prefix_len: 40, // invalid for v4
544 };
545 assert!(!r.contains(&v4(10, 0, 0, 1)));
546 }
547
548 #[test]
549 fn ip_range_v6_basic_match() {
550 let base = IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0));
551 let r = IpRange {
552 base,
553 prefix_len: 8,
554 };
555 assert!(r.contains(&IpAddr::V6(Ipv6Addr::new(0xfd01, 2, 3, 4, 5, 6, 7, 8))));
556 assert!(!r.contains(&IpAddr::V6(Ipv6Addr::new(0xfe00, 0, 0, 0, 0, 0, 0, 0))));
557 }
558
559 #[test]
560 fn ip_range_v6_prefix_zero_matches_all_v6() {
561 let r = IpRange {
562 base: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
563 prefix_len: 0,
564 };
565 assert!(r.contains(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
566 }
567
568 #[test]
569 fn ip_range_v6_out_of_range_prefix_never_matches() {
570 let r = IpRange {
571 base: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
572 prefix_len: 200, // invalid for v6
573 };
574 assert!(!r.contains(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
575 }
576
577 #[test]
578 fn ip_range_mixed_family_never_matches() {
579 let r = IpRange {
580 base: v4(10, 0, 0, 0),
581 prefix_len: 8,
582 };
583 assert!(!r.contains(&IpAddr::V6(Ipv6Addr::LOCALHOST)));
584 let r6 = IpRange {
585 base: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
586 prefix_len: 0,
587 };
588 assert!(!r6.contains(&v4(10, 0, 0, 1)));
589 }
590
591 // ---- classify_interface ----
592
593 #[test]
594 fn classify_loopback_v4() {
595 let cfg = InterfaceConfig::default();
596 assert_eq!(
597 classify_interface(&v4(127, 0, 0, 1), &cfg),
598 NetInterface::Loopback
599 );
600 assert_eq!(
601 classify_interface(&v4(127, 1, 2, 3), &cfg),
602 NetInterface::Loopback
603 );
604 }
605
606 #[test]
607 fn classify_loopback_v6() {
608 let cfg = InterfaceConfig::default();
609 assert_eq!(
610 classify_interface(&IpAddr::V6(Ipv6Addr::LOCALHOST), &cfg),
611 NetInterface::Loopback
612 );
613 }
614
615 #[test]
616 fn classify_local_subnet_after_loopback() {
617 let cfg = InterfaceConfig {
618 local_subnets: alloc::vec![IpRange {
619 base: v4(10, 0, 0, 0),
620 prefix_len: 24,
621 }],
622 ..InterfaceConfig::default()
623 };
624 match classify_interface(&v4(10, 0, 0, 5), &cfg) {
625 NetInterface::LocalSubnet(r) => {
626 assert_eq!(r.prefix_len, 24);
627 }
628 other => panic!("expected LocalSubnet, got {other:?}"),
629 }
630 }
631
632 #[test]
633 fn classify_wan_fallback() {
634 let cfg = InterfaceConfig::default();
635 assert_eq!(classify_interface(&v4(8, 8, 8, 8), &cfg), NetInterface::Wan);
636 }
637
638 #[test]
639 fn classify_named_wins_over_local_subnet() {
640 let vpn_range = IpRange {
641 base: v4(10, 8, 0, 0),
642 prefix_len: 16,
643 };
644 let mgmt_range = IpRange {
645 base: v4(10, 0, 0, 0),
646 prefix_len: 8,
647 };
648 let cfg = InterfaceConfig {
649 local_subnets: alloc::vec![mgmt_range],
650 named: alloc::vec![(vpn_range, "vpn".to_string())],
651 };
652 assert_eq!(
653 classify_interface(&v4(10, 8, 1, 2), &cfg),
654 NetInterface::Named("vpn".to_string())
655 );
656 }
657
658 #[test]
659 fn classify_named_first_match_wins() {
660 let cfg = InterfaceConfig {
661 named: alloc::vec![
662 (
663 IpRange {
664 base: v4(10, 0, 0, 0),
665 prefix_len: 8,
666 },
667 "first".to_string()
668 ),
669 (
670 IpRange {
671 base: v4(10, 0, 0, 0),
672 prefix_len: 24,
673 },
674 "second".to_string()
675 ),
676 ],
677 ..InterfaceConfig::default()
678 };
679 assert_eq!(
680 classify_interface(&v4(10, 0, 0, 5), &cfg),
681 NetInterface::Named("first".to_string())
682 );
683 }
684
685 // ---- PolicyDecision ----
686
687 #[test]
688 fn policy_decision_plain_constant() {
689 assert_eq!(
690 PolicyDecision::PLAIN,
691 PolicyDecision {
692 protection: ProtectionLevel::None,
693 suite: None,
694 drop: false,
695 }
696 );
697 }
698
699 #[test]
700 fn policy_decision_drop_constant() {
701 assert_eq!(
702 PolicyDecision::DROP,
703 PolicyDecision {
704 protection: ProtectionLevel::None,
705 suite: None,
706 drop: true,
707 }
708 );
709 assert_ne!(PolicyDecision::DROP, PolicyDecision::PLAIN);
710 }
711
712 #[test]
713 fn policy_decision_with_none_forces_suite_none() {
714 let d = PolicyDecision::with(ProtectionLevel::None, Some(SuiteHint::Aes128Gcm));
715 assert!(d.suite.is_none());
716 }
717
718 #[test]
719 fn policy_decision_with_encrypt_keeps_suite() {
720 let d = PolicyDecision::with(ProtectionLevel::Encrypt, Some(SuiteHint::Aes256Gcm));
721 assert_eq!(d.suite, Some(SuiteHint::Aes256Gcm));
722 assert_eq!(d.protection, ProtectionLevel::Encrypt);
723 assert!(!d.drop);
724 }
725}