Skip to main content

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}