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}