Skip to main content

zerodds_security_runtime/
engine.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Default-Implementation `GovernancePolicyEngine`.
5//!
6//! Diese Impl bildet die v1.4-Semantik von [`crate::SharedSecurityGate`]
7//! auf das neue [`PolicyEngine`]-Interface ab — damit Stufe 4–6 den
8//! Gate auf `PolicyEngine`-Basis refaktorieren koennen, ohne dass
9//! bestehende Deployments ihr Wire-Verhalten aendern.
10//!
11//! # Semantik
12//!
13//! Der Engine entscheidet rein aus `domain_id` + Governance-XML, ohne
14//! Peer-/Interface-Auswahl — genau wie der aktuelle Gate. Die
15//! Peer-spezifischen Entscheidungen kommen erst mit RC1
16//! (SPDP-Caps) + RC1 (`<peer_classes>`) in spezialisierte
17//! PolicyEngines hinein.
18//!
19//! # Parity-Kontrakt gegenueber `SharedSecurityGate`
20//!
21//! Die Decision aus [`GovernancePolicyEngine::outbound_decision`]
22//! bildet `ProtectionLevel` 1:1 aus
23//! `Governance::find_domain_rule(domain_id).rtps_protection_kind` —
24//! der selbe Lookup, den der Gate in `message_protection()` macht.
25//! Der zugehoerige E2E-Test in diesem Modul prueft, dass die
26//! Decision-Matrix `{None, Sign, Encrypt, SignWO, EncryptWO}` gegen
27//! den Gate identisch ausgeht.
28
29use zerodds_security_crypto::Suite;
30use zerodds_security_permissions::{Governance, ProtectionKind};
31
32use crate::caps::PeerCapabilities;
33use crate::peer_class::{interface_accepts_class, resolve_peer_class};
34use crate::policy::{
35    InboundCtx, NetInterface, OutboundCtx, PolicyDecision, PolicyEngine, ProtectionLevel, SuiteHint,
36};
37
38/// Governance-XML-getriebene `PolicyEngine`-Default-Implementation.
39///
40/// `Clone` ist bewusst NICHT `derive`d: die [`Governance`]-struct
41/// selber ist `Clone`, aber die Engine wird typischerweise als
42/// `Arc<dyn PolicyEngine>` in mehreren Runtime-Komponenten gehalten —
43/// dann ist ein `Arc::clone` der richtige Weg, nicht ein Deep-Copy.
44#[derive(Debug)]
45pub struct GovernancePolicyEngine {
46    domain_id: u32,
47    governance: Governance,
48    /// Default-Suite, wenn Protection = Encrypt. Fuer v1.4-Parity
49    /// ist das `Aes128Gcm` — derselbe Default wie im
50    /// `AesGcmCryptoPlugin::new()`.
51    default_suite: SuiteHint,
52}
53
54impl GovernancePolicyEngine {
55    /// Konstruktor mit explizitem Default-Suite. Fuer v1.4-Parity
56    /// passt [`Self::with_defaults`].
57    #[must_use]
58    pub fn new(domain_id: u32, governance: Governance, default_suite: SuiteHint) -> Self {
59        Self {
60            domain_id,
61            governance,
62            default_suite,
63        }
64    }
65
66    /// Konstruktor mit v1.4-Default-Suite (`AES_128_GCM`).
67    #[must_use]
68    pub fn with_defaults(domain_id: u32, governance: Governance) -> Self {
69        Self::new(
70            domain_id,
71            governance,
72            SuiteHint::from_suite(Suite::default()),
73        )
74    }
75
76    /// Aktuelle `ProtectionKind` aus Governance fuer die Participant-
77    /// Domain — gleicher Lookup wie [`crate::SharedSecurityGate::message_protection`].
78    #[must_use]
79    pub fn message_protection_kind(&self) -> ProtectionKind {
80        self.governance
81            .find_domain_rule(self.domain_id)
82            .map(|r| r.rtps_protection_kind)
83            .unwrap_or(ProtectionKind::None)
84    }
85
86    /// Konfigurierte Domain-Id.
87    #[must_use]
88    pub fn domain_id(&self) -> u32 {
89        self.domain_id
90    }
91
92    /// Gemeinsame Kern-Funktion fuer Out- und Inbound-Decisions:
93    /// das Protection-Level steht rein aus Domain-Rule fest.
94    fn domain_decision(&self) -> PolicyDecision {
95        let kind = self.message_protection_kind();
96        self.decision_for_kind(kind)
97    }
98
99    fn decision_for_kind(&self, kind: ProtectionKind) -> PolicyDecision {
100        let level = ProtectionLevel::from_protection_kind(kind);
101        let suite = match level {
102            ProtectionLevel::None => None,
103            ProtectionLevel::Sign => Some(SuiteHint::HmacSha256),
104            ProtectionLevel::Encrypt => Some(self.default_suite),
105        };
106        PolicyDecision::with(level, suite)
107    }
108
109    /// Auflosung der Peer-Klasse fuer einen Remote-Peer + Interface
110    ///.
111    ///
112    /// Schritte:
113    /// 1. Domain-Rule suchen (wenn keine passt → `None`).
114    /// 2. Wenn `peer_classes` leer → Legacy-Pfad → `None`.
115    /// 3. Erste matchende Peer-Klasse finden. Wenn keine matched →
116    ///    `DROP`-Entscheidung (Peer passt in keine konfigurierte
117    ///    Klasse — konservativ-sichere Haltung).
118    /// 4. Interface-Binding-Filter anwenden:
119    ///    * `peer_class_filter` leer → akzeptiert.
120    ///    * Klasse **nicht** im Filter → `DROP`.
121    /// 5. Protection-Level ermitteln:
122    ///    * Start: `peer_class.protection`.
123    ///    * Interface-`protection_override`, wenn gesetzt, hat
124    ///      Vorrang (erlaubt z.B. Loopback → NONE).
125    ///    * Interface-`protection_min` wird als Untergrenze
126    ///      angewandt (`max(level, protection_min)`).
127    fn resolve_peer_decision(
128        &self,
129        caps: &PeerCapabilities,
130        iface: &NetInterface,
131    ) -> Option<PolicyDecision> {
132        let rule = self.governance.find_domain_rule(self.domain_id)?;
133        if rule.peer_classes.is_empty() {
134            return None;
135        }
136        let class = match resolve_peer_class(caps, &rule.peer_classes) {
137            Some(c) => c,
138            None => return Some(PolicyDecision::DROP),
139        };
140
141        // Interface-Binding-Regel suchen (per Name).
142        let iface_rule = if let Some(name) = iface_name(iface) {
143            rule.interface_bindings
144                .iter()
145                .find(|b| b.name.as_str() == name)
146        } else {
147            None
148        };
149
150        if let Some(binding) = iface_rule {
151            if !interface_accepts_class(&class.name, &binding.peer_class_filter) {
152                return Some(PolicyDecision::DROP);
153            }
154        }
155
156        // Start mit Class-Protection.
157        let mut kind = class.protection;
158        // Interface-Override ersetzt.
159        if let Some(binding) = iface_rule {
160            if let Some(over) = binding.protection_override {
161                kind = over;
162            }
163            // Interface-Minimum: nach Uebersetzung in ProtectionLevel
164            // den staerkeren Wert nehmen.
165            if let Some(min) = binding.protection_min {
166                let level_cur = ProtectionLevel::from_protection_kind(kind);
167                let level_min = ProtectionLevel::from_protection_kind(min);
168                kind = level_cur.stronger(level_min).to_protection_kind();
169            }
170        }
171        Some(self.decision_for_kind(kind))
172    }
173}
174
175/// Mappt eine `NetInterface`-Variante auf den Namen, der in
176/// `<zerodds:interface name="...">` erwartet wird.
177fn iface_name(iface: &NetInterface) -> Option<&str> {
178    match iface {
179        NetInterface::Loopback => Some("loopback"),
180        NetInterface::LocalHost => Some("shm"),
181        NetInterface::Wan => Some("wan"),
182        NetInterface::LocalSubnet(_) => Some("local_subnet"),
183        NetInterface::Named(n) => Some(n.as_str()),
184    }
185}
186
187impl PolicyEngine for GovernancePolicyEngine {
188    fn outbound_decision(&self, ctx: OutboundCtx<'_>) -> PolicyDecision {
189        if let Some(dec) = self.resolve_peer_decision(ctx.remote_caps, ctx.interface) {
190            return dec;
191        }
192        self.domain_decision()
193    }
194
195    fn inbound_decision(&self, ctx: InboundCtx<'_>) -> PolicyDecision {
196        let expected = self.domain_decision();
197        // Wenn Domain plaintext erwartet und ein Paket ist SRTPS-
198        // gewrappt: Stufe 5 erweitert das — v1.4-Parity ist: wir
199        // geben die Domain-Decision zurueck, der Gate unwrappt dann.
200        if matches!(expected.protection, ProtectionLevel::None) && ctx.is_sec_prefixed {
201            // Paket wird trotzdem versucht zu entschluesseln — wie im
202            // aktuellen Gate (passthrough, kein hard-drop).
203            return expected;
204        }
205        // Wenn Domain Schutz erwartet und Paket nicht geschuetzt:
206        // der Gate liefert `PolicyViolation`. Engine-seitig markieren
207        // wir das als "drop=true" — Stufe 5 wertet das aus. Aktueller
208        // SharedSecurityGate hat diese Semantik bereits, wir spiegeln
209        // sie hier in der Decision wider.
210        if !matches!(expected.protection, ProtectionLevel::None) && !ctx.is_sec_prefixed {
211            return PolicyDecision::DROP;
212        }
213        expected
214    }
215
216    fn accept_peer(&self, _caps: &PeerCapabilities) -> bool {
217        // v1.4-Parity: der Gate filtert nicht nach Caps. Die
218        // Authentication-Plugin-Kette uebernimmt diese Rolle.
219        // Stufe 2 (SPDP-Caps) verschaerft das.
220        true
221    }
222}
223
224// ============================================================================
225// Tests — Parity-Matrix gegen SharedSecurityGate
226// ============================================================================
227
228#[cfg(test)]
229#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
230mod tests {
231    use super::*;
232    use alloc::string::{String, ToString};
233    use alloc::vec;
234
235    use zerodds_security_crypto::AesGcmCryptoPlugin;
236    use zerodds_security_permissions::parse_governance_xml;
237
238    use crate::policy::{IpRange, NetInterface};
239    use crate::shared::{PeerKey, SharedSecurityGate};
240
241    fn gov_xml(kind: &str) -> String {
242        alloc::format!(
243            r#"
244<domain_access_rules>
245  <domain_rule>
246    <domains><id>0</id></domains>
247    <rtps_protection_kind>{kind}</rtps_protection_kind>
248    <topic_access_rules><topic_rule><topic_expression>*</topic_expression></topic_rule></topic_access_rules>
249  </domain_rule>
250</domain_access_rules>
251"#
252        )
253    }
254
255    fn stub_peer() -> (PeerKey, PeerCapabilities) {
256        ([0xA1; 12], PeerCapabilities::default())
257    }
258
259    fn stub_out_ctx<'a>(
260        peer: &'a PeerKey,
261        caps: &'a PeerCapabilities,
262        iface: &'a NetInterface,
263        partition: &'a [String],
264    ) -> OutboundCtx<'a> {
265        OutboundCtx {
266            domain_id: 0,
267            topic: "Chatter",
268            partition,
269            interface: iface,
270            remote_peer: peer,
271            remote_caps: caps,
272        }
273    }
274
275    // ---- Decision-Matrix vs. SharedSecurityGate ----
276
277    #[test]
278    fn outbound_decision_matches_gate_message_protection_all_kinds() {
279        for kind in [
280            "NONE",
281            "SIGN",
282            "ENCRYPT",
283            "SIGN_WITH_ORIGIN_AUTHENTICATION",
284            "ENCRYPT_WITH_ORIGIN_AUTHENTICATION",
285        ] {
286            let gov = parse_governance_xml(&gov_xml(kind)).unwrap();
287            let engine = GovernancePolicyEngine::with_defaults(0, gov.clone());
288            let gate = SharedSecurityGate::new(0, gov, Box::new(AesGcmCryptoPlugin::new()));
289
290            let expected_kind = gate.message_protection().unwrap();
291            let expected_level = ProtectionLevel::from_protection_kind(expected_kind);
292
293            let (peer, caps) = stub_peer();
294            let iface = NetInterface::Wan;
295            let parts: Vec<String> = vec![];
296            let decision = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
297            assert_eq!(
298                decision.protection, expected_level,
299                "protection mismatch fuer kind={kind}"
300            );
301            assert!(!decision.drop);
302        }
303    }
304
305    #[test]
306    fn outbound_decision_suite_is_aes128_for_encrypt() {
307        let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
308        let engine = GovernancePolicyEngine::with_defaults(0, gov);
309        let (peer, caps) = stub_peer();
310        let iface = NetInterface::Wan;
311        let parts: Vec<String> = vec![];
312        let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
313        assert_eq!(d.suite, Some(SuiteHint::Aes128Gcm));
314    }
315
316    #[test]
317    fn outbound_decision_suite_is_hmac_for_sign() {
318        let gov = parse_governance_xml(&gov_xml("SIGN")).unwrap();
319        let engine = GovernancePolicyEngine::with_defaults(0, gov);
320        let (peer, caps) = stub_peer();
321        let iface = NetInterface::Wan;
322        let parts: Vec<String> = vec![];
323        let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
324        assert_eq!(d.suite, Some(SuiteHint::HmacSha256));
325        assert_eq!(d.protection, ProtectionLevel::Sign);
326    }
327
328    #[test]
329    fn outbound_decision_suite_is_none_for_none() {
330        let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
331        let engine = GovernancePolicyEngine::with_defaults(0, gov);
332        let (peer, caps) = stub_peer();
333        let iface = NetInterface::Loopback;
334        let parts: Vec<String> = vec![];
335        let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
336        assert!(d.suite.is_none());
337        assert_eq!(d.protection, ProtectionLevel::None);
338    }
339
340    #[test]
341    fn outbound_decision_custom_suite_roundtrip() {
342        let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
343        let engine = GovernancePolicyEngine::new(0, gov, SuiteHint::Aes256Gcm);
344        let (peer, caps) = stub_peer();
345        let iface = NetInterface::Wan;
346        let parts: Vec<String> = vec![];
347        let d = engine.outbound_decision(stub_out_ctx(&peer, &caps, &iface, &parts));
348        assert_eq!(d.suite, Some(SuiteHint::Aes256Gcm));
349    }
350
351    #[test]
352    fn inbound_plain_on_protected_domain_is_drop() {
353        let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
354        let engine = GovernancePolicyEngine::with_defaults(0, gov);
355        let peer: PeerKey = [1; 12];
356        let iface = NetInterface::Wan;
357        let d = engine.inbound_decision(InboundCtx {
358            domain_id: 0,
359            source_peer: &peer,
360            source_iface: &iface,
361            source_caps: None,
362            is_sec_prefixed: false,
363        });
364        assert!(d.drop, "plaintext auf protected domain muss droppen");
365    }
366
367    #[test]
368    fn inbound_secure_on_protected_domain_is_decrypt() {
369        let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
370        let engine = GovernancePolicyEngine::with_defaults(0, gov);
371        let peer: PeerKey = [1; 12];
372        let iface = NetInterface::Wan;
373        let d = engine.inbound_decision(InboundCtx {
374            domain_id: 0,
375            source_peer: &peer,
376            source_iface: &iface,
377            source_caps: None,
378            is_sec_prefixed: true,
379        });
380        assert!(!d.drop);
381        assert_eq!(d.protection, ProtectionLevel::Encrypt);
382    }
383
384    #[test]
385    fn inbound_plain_on_plain_domain_is_accept() {
386        let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
387        let engine = GovernancePolicyEngine::with_defaults(0, gov);
388        let peer: PeerKey = [1; 12];
389        let iface = NetInterface::Loopback;
390        let d = engine.inbound_decision(InboundCtx {
391            domain_id: 0,
392            source_peer: &peer,
393            source_iface: &iface,
394            source_caps: None,
395            is_sec_prefixed: false,
396        });
397        assert!(!d.drop);
398        assert_eq!(d.protection, ProtectionLevel::None);
399    }
400
401    #[test]
402    fn inbound_secure_on_plain_domain_passthrough() {
403        // v1.4-SharedSecurityGate akzeptiert SRTPS auf plain domain
404        // und unwrappt. Unsere Engine liefert die Domain-Decision
405        // zurueck (None) — dann entscheidet der Gate/Plugin.
406        let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
407        let engine = GovernancePolicyEngine::with_defaults(0, gov);
408        let peer: PeerKey = [1; 12];
409        let iface = NetInterface::Loopback;
410        let d = engine.inbound_decision(InboundCtx {
411            domain_id: 0,
412            source_peer: &peer,
413            source_iface: &iface,
414            source_caps: None,
415            is_sec_prefixed: true,
416        });
417        assert!(!d.drop);
418        assert_eq!(d.protection, ProtectionLevel::None);
419    }
420
421    #[test]
422    fn accept_peer_is_always_true_in_v14_parity() {
423        let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
424        let engine = GovernancePolicyEngine::with_defaults(0, gov);
425        assert!(engine.accept_peer(&PeerCapabilities::default()));
426        assert!(engine.accept_peer(&PeerCapabilities {
427            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".to_string()),
428            ..Default::default()
429        }));
430    }
431
432    #[test]
433    fn message_protection_kind_falls_back_to_none_when_domain_not_listed() {
434        // Governance hat nur domain_id=0, Engine fragt nach domain_id=99.
435        let gov = parse_governance_xml(&gov_xml("ENCRYPT")).unwrap();
436        let engine = GovernancePolicyEngine::with_defaults(99, gov);
437        assert_eq!(engine.message_protection_kind(), ProtectionKind::None);
438    }
439
440    #[test]
441    fn domain_id_accessor_returns_constructor_value() {
442        let gov = parse_governance_xml(&gov_xml("NONE")).unwrap();
443        let engine = GovernancePolicyEngine::with_defaults(42, gov);
444        assert_eq!(engine.domain_id(), 42);
445    }
446
447    // Nutzt IpRange-Import fuer Compilation-Check
448    #[test]
449    fn interface_classification_is_independent_of_engine() {
450        let _r = IpRange {
451            base: core::net::IpAddr::V4(core::net::Ipv4Addr::new(10, 0, 0, 0)),
452            prefix_len: 24,
453        };
454    }
455
456    // =======================================================================
457    // RC1 Stufe 8 — Peer-Class-Integration in GovernancePolicyEngine
458    // =======================================================================
459
460    const HETERO_GOV_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
461<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
462  <domain_access_rules>
463    <domain_rule>
464      <domains><id>0</id></domains>
465      <rtps_protection_kind>SIGN</rtps_protection_kind>
466      <zerodds:peer_classes>
467        <zerodds:peer_class name="legacy" protection="NONE">
468          <zerodds:match auth_plugin_class="" />
469        </zerodds:peer_class>
470        <zerodds:peer_class name="fast" protection="SIGN">
471          <zerodds:match cert_cn_pattern="*.fast.example" />
472        </zerodds:peer_class>
473        <zerodds:peer_class name="secure" protection="ENCRYPT">
474          <zerodds:match auth_plugin_class="DDS:Auth:PKI-DH:1.2" suite="AES_128_GCM" />
475        </zerodds:peer_class>
476        <zerodds:peer_class name="highassurance" protection="ENCRYPT">
477          <zerodds:match cert_cn_pattern="*.ha.*" suite="AES_256_GCM" require_ocsp="TRUE" />
478        </zerodds:peer_class>
479      </zerodds:peer_classes>
480      <zerodds:interface_bindings>
481        <zerodds:interface name="loopback" protection_override="NONE" />
482        <zerodds:interface name="shm"      protection_override="NONE" />
483        <zerodds:interface name="eth0"     peer_class_filter="legacy,fast,secure" />
484        <zerodds:interface name="tun0"     peer_class_filter="secure,highassurance"
485                                           protection_min="ENCRYPT" />
486      </zerodds:interface_bindings>
487    </domain_rule>
488  </domain_access_rules>
489</dds>"#;
490
491    fn hetero_engine() -> GovernancePolicyEngine {
492        let gov = parse_governance_xml(HETERO_GOV_XML).unwrap();
493        GovernancePolicyEngine::with_defaults(0, gov)
494    }
495
496    fn mk_out_ctx<'a>(
497        peer: &'a PeerKey,
498        caps: &'a PeerCapabilities,
499        iface: &'a NetInterface,
500        parts: &'a [String],
501    ) -> OutboundCtx<'a> {
502        OutboundCtx {
503            domain_id: 0,
504            topic: "Chatter",
505            partition: parts,
506            interface: iface,
507            remote_peer: peer,
508            remote_caps: caps,
509        }
510    }
511
512    #[test]
513    fn hetero_dod_legacy_peer_on_eth0_gets_none() {
514        // Plan §Stufe 8 DoD-Matrix: Legacy-Peer auf eth0 → NONE.
515        let engine = hetero_engine();
516        let peer: PeerKey = [1; 12];
517        let caps = PeerCapabilities::default();
518        let iface = NetInterface::Named("eth0".into());
519        let parts: Vec<String> = vec![];
520        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
521        assert_eq!(dec.protection, ProtectionLevel::None);
522        assert!(!dec.drop);
523    }
524
525    #[test]
526    fn hetero_dod_fast_peer_on_eth0_gets_sign() {
527        let engine = hetero_engine();
528        let peer: PeerKey = [2; 12];
529        let caps = PeerCapabilities {
530            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
531            cert_cn: Some("writer.fast.example".into()),
532            supported_suites: vec![SuiteHint::HmacSha256],
533            ..Default::default()
534        };
535        let iface = NetInterface::Named("eth0".into());
536        let parts: Vec<String> = vec![];
537        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
538        assert_eq!(dec.protection, ProtectionLevel::Sign);
539    }
540
541    #[test]
542    fn hetero_dod_secure_peer_on_eth0_gets_encrypt() {
543        let engine = hetero_engine();
544        let peer: PeerKey = [3; 12];
545        let caps = PeerCapabilities {
546            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
547            supported_suites: vec![SuiteHint::Aes128Gcm],
548            ..Default::default()
549        };
550        let iface = NetInterface::Named("eth0".into());
551        let parts: Vec<String> = vec![];
552        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
553        assert_eq!(dec.protection, ProtectionLevel::Encrypt);
554    }
555
556    #[test]
557    fn hetero_dod_ha_peer_on_tun0_gets_encrypt() {
558        let engine = hetero_engine();
559        let peer: PeerKey = [4; 12];
560        let caps = PeerCapabilities {
561            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
562            cert_cn: Some("w1.ha.corp".into()),
563            supported_suites: vec![SuiteHint::Aes256Gcm],
564            has_valid_cert: true,
565            ..Default::default()
566        };
567        let iface = NetInterface::Named("tun0".into());
568        let parts: Vec<String> = vec![];
569        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
570        assert_eq!(dec.protection, ProtectionLevel::Encrypt);
571    }
572
573    #[test]
574    fn hetero_interface_override_loopback_forces_none() {
575        // Interface-Binding loopback hat protection_override=NONE —
576        // selbst ein secure-Peer darf auf Loopback plain senden.
577        let engine = hetero_engine();
578        let peer: PeerKey = [5; 12];
579        let caps = PeerCapabilities {
580            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
581            supported_suites: vec![SuiteHint::Aes128Gcm],
582            ..Default::default()
583        };
584        let iface = NetInterface::Loopback;
585        let parts: Vec<String> = vec![];
586        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
587        assert_eq!(
588            dec.protection,
589            ProtectionLevel::None,
590            "loopback-override muss Class-Encrypt ueberschreiben"
591        );
592    }
593
594    #[test]
595    fn hetero_interface_filter_rejects_legacy_on_tun0() {
596        // tun0 hat peer_class_filter="secure,highassurance". Ein
597        // Legacy-Peer muss droppen.
598        let engine = hetero_engine();
599        let peer: PeerKey = [6; 12];
600        let caps = PeerCapabilities::default(); // legacy
601        let iface = NetInterface::Named("tun0".into());
602        let parts: Vec<String> = vec![];
603        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
604        assert!(dec.drop, "Legacy-Peer darf nicht auf tun0 → drop");
605    }
606
607    #[test]
608    fn hetero_no_matching_peer_class_drops() {
609        // Ein Peer dessen Caps keine der 4 Klassen matchen → Drop
610        // (konservativ-sichere Haltung).
611        let engine = hetero_engine();
612        let peer: PeerKey = [7; 12];
613        let caps = PeerCapabilities {
614            // Hat Auth (also kein legacy), cert-CN matcht weder fast
615            // noch ha, Suite leer (also kein secure), kein OCSP.
616            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
617            cert_cn: Some("unknown.zone".into()),
618            supported_suites: vec![],
619            ..Default::default()
620        };
621        let iface = NetInterface::Named("eth0".into());
622        let parts: Vec<String> = vec![];
623        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
624        assert!(dec.drop, "Peer in keiner Klasse → drop");
625    }
626
627    #[test]
628    fn hetero_interface_protection_min_upgrades_sign_to_encrypt() {
629        // tun0 hat protection_min=ENCRYPT — ein secure-Peer ist
630        // bereits ENCRYPT (stronger_wins ändert nichts).
631        // Wichtiger: ein fast-Peer (SIGN) wuerde auf tun0 als DROP
632        // enden, weil fast nicht im filter ist. Also testen wir mit
633        // einem secure-Peer der nur durch protection_min auf ENCRYPT
634        // bleibt.
635        let engine = hetero_engine();
636        let peer: PeerKey = [8; 12];
637        let caps = PeerCapabilities {
638            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
639            supported_suites: vec![SuiteHint::Aes128Gcm],
640            ..Default::default()
641        };
642        let iface = NetInterface::Named("tun0".into());
643        let parts: Vec<String> = vec![];
644        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
645        assert_eq!(dec.protection, ProtectionLevel::Encrypt);
646    }
647
648    #[test]
649    fn legacy_xml_without_peer_classes_falls_back_to_domain_rule() {
650        // Ein reines OMG-Governance-XML ohne peer_classes soll
651        // exakt wie v1.4 entscheiden (Domain-Rule wins).
652        let engine = GovernancePolicyEngine::with_defaults(
653            0,
654            parse_governance_xml(&gov_xml("SIGN")).unwrap(),
655        );
656        let peer: PeerKey = [9; 12];
657        let caps = PeerCapabilities {
658            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
659            supported_suites: vec![SuiteHint::Aes128Gcm],
660            ..Default::default()
661        };
662        let iface = NetInterface::Wan;
663        let parts: Vec<String> = vec![];
664        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
665        assert_eq!(
666            dec.protection,
667            ProtectionLevel::Sign,
668            "ohne peer_classes muss Domain-Rule greifen"
669        );
670    }
671}