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 und Datentypen
5//!.
6//!
7//! Diese Schicht ist die Abstraktion ueber dem Governance-XML-Stack.
8//! Der v1.4-Stand ([`crate::SharedSecurityGate`]) entscheidet **ein**
9//! Protection-Level pro Participant. System-of-Systems-Deployments
10//! (Vehicle, Tactical, Edge) brauchen feinere Koernung auf der Tripel-
11//! Achse `(peer, topic, interface)`.
12//!
13//! [`PolicyEngine`] kapselt diese Entscheidung:
14//! * [`PolicyEngine::outbound_decision`] wird pro matched Reader
15//!   aufgerufen, bevor ein Wire-Paket geschrieben wird.
16//! * [`PolicyEngine::inbound_decision`] wird pro eingehendem Datagramm
17//!   aufgerufen.
18//! * [`PolicyEngine::accept_peer`] ist der Admission-Check waehrend
19//!   SEDP-Matching.
20//!
21//! Die Default-Implementation ([`crate::GovernancePolicyEngine`], in
22//! Stufe 1c) bildet die aktuelle `SharedSecurityGate`-Semantik 1:1 nach.
23//! Nutzer koennen eigene `PolicyEngine`-Impls einstecken, z.B. um
24//! Entscheidungen aus einem externen Policy-Server oder einer Vehicle-
25//! Network-Certification-Datenbank zu beziehen.
26//!
27//! Siehe `docs/architecture/08_heterogeneous_security.md` §3.1.
28
29use alloc::string::String;
30use alloc::vec::Vec;
31
32use core::net::IpAddr;
33
34use zerodds_security_crypto::Suite;
35use zerodds_security_permissions::ProtectionKind;
36
37use crate::caps::PeerCapabilities;
38use crate::shared::PeerKey;
39
40// ============================================================================
41// Grundtypen
42// ============================================================================
43
44/// Abstraktes Schutz-Level fuer die Policy-Ebene.
45///
46/// Im Gegensatz zu [`ProtectionKind`] (XML-Parser-Typ, 5 Varianten
47/// inkl. Origin-Authentication) traegt `ProtectionLevel` nur die 3
48/// Grund-Klassen. Policy-Entscheidungen vergleichen/ordnen diese
49/// Stufen — die Origin-Auth-Verfeinerung folgt aus
50/// [`PolicyDecision::suite`] (Receiver-Specific-MACs, RC1).
51///
52/// Die Reihenfolge `None < Sign < Encrypt` ist fuer das "staerkster
53/// Wert gewinnt"-Matching in Stufe 3 (SEDP-Endpoint-Caps) relevant
54/// — `Ord`/`PartialOrd` sind entsprechend definiert.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
56pub enum ProtectionLevel {
57    /// Kein Schutz — plaintext-RTPS auf dem Wire.
58    #[default]
59    None,
60    /// Integrity-Schutz (HMAC/AEAD-Tag), Payload bleibt lesbar.
61    Sign,
62    /// Integrity + Confidentiality (AEAD-Ciphertext).
63    Encrypt,
64}
65
66impl ProtectionLevel {
67    /// Mapping aus Governance-XML-[`ProtectionKind`]. Origin-Auth-
68    /// Varianten kollabieren auf ihre Grund-Klasse; die Origin-Auth-
69    /// Eigenschaft wird per [`PolicyDecision::suite`] +
70    /// Receiver-Specific-MAC-Encoding transportiert.
71    #[must_use]
72    pub fn from_protection_kind(kind: ProtectionKind) -> Self {
73        match kind {
74            ProtectionKind::None => Self::None,
75            ProtectionKind::Sign | ProtectionKind::SignWithOriginAuthentication => Self::Sign,
76            ProtectionKind::Encrypt | ProtectionKind::EncryptWithOriginAuthentication => {
77                Self::Encrypt
78            }
79        }
80    }
81
82    /// Rueck-Mapping auf [`ProtectionKind`] ohne Origin-Auth-Verfeinerung.
83    #[must_use]
84    pub fn to_protection_kind(self) -> ProtectionKind {
85        match self {
86            Self::None => ProtectionKind::None,
87            Self::Sign => ProtectionKind::Sign,
88            Self::Encrypt => ProtectionKind::Encrypt,
89        }
90    }
91
92    /// Wahl des staerkeren von zwei Leveln (z.B. Writer-
93    /// vs. Reader-Offer).
94    #[must_use]
95    pub fn stronger(self, other: Self) -> Self {
96        if self >= other { self } else { other }
97    }
98}
99
100/// Crypto-Suite-Hinweis fuer die Policy-Entscheidung.
101///
102/// `SuiteHint` ist ein **Wunsch** der Policy-Engine — das Crypto-
103/// Plugin kann ihn ignorieren, wenn es den Algorithmus nicht
104/// unterstuetzt. Das konkrete [`Suite`]-Enum (`security-crypto`)
105/// ist der Plugin-interne Typ; diese Indirektion erlaubt zukuenftige
106/// Suiten (ChaCha20-Poly1305, AES-CCM) ohne Breaking Change am
107/// Policy-API.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub enum SuiteHint {
110    /// AES-128-GCM — Default-Suite v1.4.
111    Aes128Gcm,
112    /// AES-256-GCM — fuer Langzeit-Vertraulichkeit / Compliance.
113    Aes256Gcm,
114    /// HMAC-SHA256 Auth-only (keine Confidentiality, SIGN-Level).
115    HmacSha256,
116}
117
118impl SuiteHint {
119    /// Mapping auf das Plugin-interne [`Suite`].
120    #[must_use]
121    pub fn to_suite(self) -> Suite {
122        match self {
123            Self::Aes128Gcm => Suite::Aes128Gcm,
124            Self::Aes256Gcm => Suite::Aes256Gcm,
125            Self::HmacSha256 => Suite::HmacSha256,
126        }
127    }
128
129    /// Rueck-Mapping aus [`Suite`].
130    #[must_use]
131    pub fn from_suite(suite: Suite) -> Self {
132        match suite {
133            Suite::Aes128Gcm => Self::Aes128Gcm,
134            Suite::Aes256Gcm => Self::Aes256Gcm,
135            Suite::HmacSha256 => Self::HmacSha256,
136        }
137    }
138
139    /// Liefert das natuerliche Protection-Level dieser Suite:
140    /// AEAD-Suiten → `Encrypt`, HMAC → `Sign`.
141    #[must_use]
142    pub fn protection_level(self) -> ProtectionLevel {
143        match self {
144            Self::Aes128Gcm | Self::Aes256Gcm => ProtectionLevel::Encrypt,
145            Self::HmacSha256 => ProtectionLevel::Sign,
146        }
147    }
148}
149
150// ============================================================================
151// NetInterface
152// ============================================================================
153
154/// CIDR-artige IPv4/IPv6-Range, inklusiv interpretiert.
155///
156/// Minimaler Selbstbau (kein `ipnet`-Dep, um den Safety-Footprint
157/// klein zu halten). Praefix-Laenge in Host-Bits: IPv4 bis 32, IPv6
158/// bis 128. Parsing (z.B. aus `"10.0.0.0/24"`) kommt in RC1
159/// (Governance-XML); hier genuegt die struct-Form.
160#[derive(Debug, Clone, PartialEq, Eq, Hash)]
161pub struct IpRange {
162    /// Basis-Adresse der Range (Host-Bits werden ignoriert).
163    pub base: IpAddr,
164    /// Praefix-Laenge in Bits. Muss `<= 32` fuer v4, `<= 128` fuer v6.
165    pub prefix_len: u8,
166}
167
168impl IpRange {
169    /// Prueft, ob `addr` in der Range liegt. Gemischte Familien
170    /// (v4 in v6-Range) sind **kein** Match.
171    #[must_use]
172    pub fn contains(&self, addr: &IpAddr) -> bool {
173        match (self.base, addr) {
174            (IpAddr::V4(base), IpAddr::V4(a)) => {
175                if self.prefix_len > 32 {
176                    return false;
177                }
178                let shift = 32 - u32::from(self.prefix_len);
179                let base_u = u32::from(base);
180                let a_u = u32::from(*a);
181                if self.prefix_len == 0 {
182                    true
183                } else {
184                    (base_u >> shift) == (a_u >> shift)
185                }
186            }
187            (IpAddr::V6(base), IpAddr::V6(a)) => {
188                if self.prefix_len > 128 {
189                    return false;
190                }
191                let base_u = u128::from(base);
192                let a_u = u128::from(*a);
193                if self.prefix_len == 0 {
194                    true
195                } else {
196                    let shift = 128 - u32::from(self.prefix_len);
197                    (base_u >> shift) == (a_u >> shift)
198                }
199            }
200            _ => false,
201        }
202    }
203}
204
205/// Klassifikation eines Netz-Interfaces fuer die Policy-Entscheidung.
206///
207/// Die Engine kann darauf basierend unterschiedlich entscheiden:
208/// * `Loopback` + `LocalHost` → oft `ProtectionLevel::None` (Bytes
209///   verlassen den Host nicht).
210/// * `LocalSubnet` → Management-Netze mit `Sign` statt `Encrypt`.
211/// * `Wan` → restriktivste Policy.
212/// * `Named` → benutzer-konfigurierte Klassifikation (z.B. `tun0`
213///   als VPN-geschuetzt, `can0-gw` als Vehicle-Bus-Gateway).
214#[derive(Debug, Clone, PartialEq, Eq, Hash)]
215pub enum NetInterface {
216    /// 127.0.0.0/8 oder `::1`.
217    Loopback,
218    /// Host-lokaler Transport ausserhalb von Loopback (Shared-Memory,
219    /// Unix-Domain-Socket) — Bytes verlassen den Host-Kernel nicht.
220    LocalHost,
221    /// Adresse in einer konfigurierten privaten Range (z.B.
222    /// `10.0.0.0/24`, `192.168.0.0/16`).
223    LocalSubnet(IpRange),
224    /// Alles andere — oeffentliche IP, unbekanntes Interface.
225    Wan,
226    /// Benutzer-konfigurierte Interface-Klasse per Name.
227    Named(String),
228}
229
230/// Laufzeit-Konfiguration des Interface-Classifiers.
231///
232/// Wird vom Nutzer beim Participant-Start gebaut und an den
233/// [`PolicyEngine`] uebergeben. Leere Konfiguration → jede nicht-
234/// Loopback-Adresse landet in [`NetInterface::Wan`].
235///
236/// Die `named`-Liste erlaubt Muster wie "alle Adressen im Tun0-Subnet
237/// sollen `NetInterface::Named("vpn".into())` werden". Die Liste
238/// wird in Reihenfolge abgearbeitet — erstes Match gewinnt.
239#[derive(Debug, Clone, Default)]
240pub struct InterfaceConfig {
241    /// Private Subnetze, die als [`NetInterface::LocalSubnet`]
242    /// klassifiziert werden.
243    pub local_subnets: Vec<IpRange>,
244    /// Named-Interface-Zuordnungen: `(range, name)` — erstes Match
245    /// liefert [`NetInterface::Named`].
246    pub named: Vec<(IpRange, String)>,
247}
248
249/// Klassifiziert eine IP-Adresse in die `NetInterface`-Taxonomie.
250///
251/// Reihenfolge:
252/// 1. Loopback (127/8, `::1`).
253/// 2. Named-Matches aus `config.named` (erstes Match gewinnt).
254/// 3. Local-Subnet-Matches aus `config.local_subnets`.
255/// 4. Fallback `Wan`.
256#[must_use]
257pub fn classify_interface(addr: &IpAddr, config: &InterfaceConfig) -> NetInterface {
258    if addr.is_loopback() {
259        return NetInterface::Loopback;
260    }
261    for (range, name) in &config.named {
262        if range.contains(addr) {
263            return NetInterface::Named(name.clone());
264        }
265    }
266    for range in &config.local_subnets {
267        if range.contains(addr) {
268            return NetInterface::LocalSubnet(range.clone());
269        }
270    }
271    NetInterface::Wan
272}
273
274// ============================================================================
275// Policy-Decision
276// ============================================================================
277
278/// Entscheidung der [`PolicyEngine`] fuer ein konkretes
279/// Paket/Peer/Interface-Tripel.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct PolicyDecision {
282    /// Verlangtes Schutz-Level.
283    pub protection: ProtectionLevel,
284    /// Gewuenschte Crypto-Suite. `None` bei `ProtectionLevel::None`
285    /// oder wenn die Engine dem Plugin die Wahl ueberlaesst.
286    pub suite: Option<SuiteHint>,
287    /// Harter Drop — Paket wird nicht zugestellt, Peer nicht
288    /// akzeptiert. Falls `true` sind `protection`/`suite` irrelevant.
289    pub drop: bool,
290}
291
292impl PolicyDecision {
293    /// Kurzform: "plaintext akzeptiert/erwartet".
294    pub const PLAIN: Self = Self {
295        protection: ProtectionLevel::None,
296        suite: None,
297        drop: false,
298    };
299
300    /// Kurzform: "hart droppen".
301    pub const DROP: Self = Self {
302        protection: ProtectionLevel::None,
303        suite: None,
304        drop: true,
305    };
306
307    /// Baut eine Decision aus Protection + Suite. Bei
308    /// `ProtectionLevel::None` wird `suite` auf `None` gezwungen.
309    #[must_use]
310    pub fn with(protection: ProtectionLevel, suite: Option<SuiteHint>) -> Self {
311        let suite = if matches!(protection, ProtectionLevel::None) {
312            None
313        } else {
314            suite
315        };
316        Self {
317            protection,
318            suite,
319            drop: false,
320        }
321    }
322}
323
324// ============================================================================
325// Context-Objects
326// ============================================================================
327
328/// Outbound-Entscheidungs-Context: ein Writer schickt an einen Peer.
329#[derive(Debug)]
330pub struct OutboundCtx<'a> {
331    /// Domain, in der der Writer lebt.
332    pub domain_id: u32,
333    /// Topic-Name (fuer Governance-`topic_rule`-Matching).
334    pub topic: &'a str,
335    /// Partition-Namen (fuer Permissions-Check).
336    pub partition: &'a [String],
337    /// Klasse des Interfaces, auf dem das Paket rausgeht.
338    pub interface: &'a NetInterface,
339    /// GuidPrefix des Remote-Peers.
340    pub remote_peer: &'a PeerKey,
341    /// Capability-Snapshot des Remote-Peers (aus SPDP/SEDP).
342    pub remote_caps: &'a PeerCapabilities,
343}
344
345/// Inbound-Entscheidungs-Context: ein Datagramm ist reingekommen.
346#[derive(Debug)]
347pub struct InboundCtx<'a> {
348    /// Domain, in der der Empfaenger lebt.
349    pub domain_id: u32,
350    /// GuidPrefix des Senders (aus RTPS-Header Bytes 8..20).
351    pub source_peer: &'a PeerKey,
352    /// Klasse des Empfangs-Interfaces.
353    pub source_iface: &'a NetInterface,
354    /// Capability-Snapshot des Senders. `None` wenn der Peer noch nie
355    /// ein SPDP-Announce geschickt hat (Legacy-Vendor oder
356    /// Pre-Discovery).
357    pub source_caps: Option<&'a PeerCapabilities>,
358    /// `true` wenn das Paket mit `SRTPS_PREFIX` beginnt (also laut
359    /// Wire-Format schon geschuetzt ist).
360    pub is_sec_prefixed: bool,
361}
362
363// ============================================================================
364// Trait
365// ============================================================================
366
367/// Policy-Engine: entscheidet fuer ein konkretes `(peer, topic,
368/// interface)`-Tripel das Schutz-Level.
369///
370/// # Safety-Klassifikation
371///
372/// Trait ist `Send + Sync`, damit er via `Arc<dyn PolicyEngine>` in
373/// Multi-Thread-Runtime genutzt werden kann. Das triggert
374/// `zerodds-lint: allow no_dyn_in_safe` (dokumentiert in
375/// `08_heterogeneous_security.md` §7).
376///
377/// # Default-Contract
378///
379/// * Implementationen muessen **deterministisch** sein: gleiche
380///   Context-Inputs → gleiche Decision. Kein Zufall, keine
381///   Zeit-abhaengigen Branches (sonst sind Replay-Angriffe moeglich).
382/// * `accept_peer` darf `false` zurueckgeben, wenn der Peer nicht
383///   die Minimal-Anforderungen (z.B. fehlendes `auth_plugin_class`
384///   bei Domain mit `allow_unauthenticated_participants=false`)
385///   erfuellt.
386/// * `outbound_decision`/`inbound_decision` duerfen **nicht**
387///   blockieren — sie laufen im Hot-Path.
388pub trait PolicyEngine: Send + Sync {
389    /// Outbound-Pfad: welches Schutz-Level soll das Wire-Paket haben?
390    fn outbound_decision(&self, ctx: OutboundCtx<'_>) -> PolicyDecision;
391
392    /// Inbound-Pfad: Paket akzeptieren / droppen / entschluesseln?
393    fn inbound_decision(&self, ctx: InboundCtx<'_>) -> PolicyDecision;
394
395    /// SEDP-Admission: ist dieser Peer (laut Capabilities)
396    /// grundsaetzlich akzeptabel fuer einen 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}