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//! This impl maps the v1.4 semantics of [`crate::SharedSecurityGate`]
7//! onto the new [`PolicyEngine`] interface — so that stages 4–6 can
8//! refactor the gate onto a `PolicyEngine` basis without
9//! existing deployments changing their wire behavior.
10//!
11//! # Semantics
12//!
13//! The engine decides purely from `domain_id` + governance XML, without
14//! peer/interface selection — exactly like the current gate. The
15//! peer-specific decisions only arrive with RC1
16//! (SPDP caps) + RC1 (`<peer_classes>`) into specialized
17//! PolicyEngines.
18//!
19//! # Parity contract against `SharedSecurityGate`
20//!
21//! The decision from [`GovernancePolicyEngine::outbound_decision`]
22//! maps `ProtectionLevel` 1:1 from
23//! `Governance::find_domain_rule(domain_id).rtps_protection_kind` —
24//! the same lookup that the gate does in `message_protection()`.
25//! The corresponding E2E test in this module checks that the
26//! decision matrix `{None, Sign, Encrypt, SignWO, EncryptWO}` comes out
27//! identical to the gate.
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-driven `PolicyEngine` default implementation.
39///
40/// `Clone` is deliberately NOT `derive`d: the [`Governance`] struct
41/// itself is `Clone`, but the engine is typically held as an
42/// `Arc<dyn PolicyEngine>` in several runtime components —
43/// then an `Arc::clone` is the right way, not a deep copy.
44#[derive(Debug)]
45pub struct GovernancePolicyEngine {
46    domain_id: u32,
47    governance: Governance,
48    /// Default suite when protection = Encrypt. For v1.4 parity
49    /// this is `Aes128Gcm` — the same default as in
50    /// `AesGcmCryptoPlugin::new()`.
51    default_suite: SuiteHint,
52}
53
54impl GovernancePolicyEngine {
55    /// Constructor with an explicit default suite. For v1.4 parity
56    /// use [`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    /// Constructor with the 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    /// Current `ProtectionKind` from governance for the participant
77    /// domain — same lookup as [`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    /// Configured domain id.
87    #[must_use]
88    pub fn domain_id(&self) -> u32 {
89        self.domain_id
90    }
91
92    /// Shared core function for out- and inbound decisions:
93    /// the protection level is fixed purely from the domain rule.
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    /// Resolution of the peer class for a remote peer + interface
110    ///.
111    ///
112    /// Steps:
113    /// 1. Find the domain rule (if none matches → `None`).
114    /// 2. If `peer_classes` is empty → legacy path → `None`.
115    /// 3. Find the first matching peer class. If none matched →
116    ///    `DROP` decision (the peer fits no configured
117    ///    class — conservative-safe stance).
118    /// 4. Apply the interface-binding filter:
119    ///    * `peer_class_filter` empty → accepted.
120    ///    * class **not** in the filter → `DROP`.
121    /// 5. Determine the protection level:
122    ///    * start: `peer_class.protection`.
123    ///    * interface `protection_override`, if set, takes
124    ///      precedence (allows e.g. loopback → NONE).
125    ///    * interface `protection_min` is applied as a lower bound
126    ///      (`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        // Find the interface-binding rule (by 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 with the class protection.
157        let mut kind = class.protection;
158        // Interface override replaces.
159        if let Some(binding) = iface_rule {
160            if let Some(over) = binding.protection_override {
161                kind = over;
162            }
163            // Interface minimum: after translation into ProtectionLevel
164            // take the stronger value.
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/// Maps a `NetInterface` variant to the name expected in
176/// `<zerodds:interface name="...">`.
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        // If the domain expects plaintext and a packet is SRTPS-
198        // wrapped: stage 5 extends this — v1.4 parity is: we
199        // return the domain decision, the gate then unwraps.
200        if matches!(expected.protection, ProtectionLevel::None) && ctx.is_sec_prefixed {
201            // The packet is still attempted to be decrypted — as in the
202            // current gate (passthrough, no hard drop).
203            return expected;
204        }
205        // If the domain expects protection and the packet is not protected:
206        // the gate returns `PolicyViolation`. On the engine side we mark
207        // this as "drop=true" — stage 5 evaluates it. The current
208        // SharedSecurityGate already has these semantics, we mirror
209        // them here in the decision.
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: the gate does not filter by caps. The
218        // authentication plugin chain takes that role.
219        // Stage 2 (SPDP caps) tightens this.
220        true
221    }
222}
223
224// ============================================================================
225// Tests — parity matrix against 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 for 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 on a protected domain must drop");
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        // The v1.4 SharedSecurityGate accepts SRTPS on a plain domain
404        // and unwraps. Our engine returns the domain decision
405        // (None) — then the gate/plugin decides.
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 only has domain_id=0, the engine asks for 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    // Uses the IpRange import for a 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 stage 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 §stage 8 DoD matrix: legacy peer on 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 has protection_override=NONE —
576        // even a secure peer may send plain on loopback.
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 must override class Encrypt"
591        );
592    }
593
594    #[test]
595    fn hetero_interface_filter_rejects_legacy_on_tun0() {
596        // tun0 has peer_class_filter="secure,highassurance". A
597        // legacy peer must drop.
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 must not be on tun0 → drop");
605    }
606
607    #[test]
608    fn hetero_no_matching_peer_class_drops() {
609        // A peer whose caps match none of the 4 classes → drop
610        // (conservative-safe stance).
611        let engine = hetero_engine();
612        let peer: PeerKey = [7; 12];
613        let caps = PeerCapabilities {
614            // Has auth (so not legacy), cert CN matches neither fast
615            // nor ha, suite empty (so not secure), no 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 no class → drop");
625    }
626
627    #[test]
628    fn hetero_interface_protection_min_upgrades_sign_to_encrypt() {
629        // tun0 has protection_min=ENCRYPT — a secure peer is
630        // already ENCRYPT (stronger_wins changes nothing).
631        // More important: a fast peer (SIGN) would end up as DROP on
632        // tun0, because fast is not in the filter. So we test with
633        // a secure peer that stays at ENCRYPT only via protection_min.
634        let engine = hetero_engine();
635        let peer: PeerKey = [8; 12];
636        let caps = PeerCapabilities {
637            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
638            supported_suites: vec![SuiteHint::Aes128Gcm],
639            ..Default::default()
640        };
641        let iface = NetInterface::Named("tun0".into());
642        let parts: Vec<String> = vec![];
643        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
644        assert_eq!(dec.protection, ProtectionLevel::Encrypt);
645    }
646
647    #[test]
648    fn legacy_xml_without_peer_classes_falls_back_to_domain_rule() {
649        // A pure OMG governance XML without peer_classes should
650        // decide exactly like v1.4 (domain rule wins).
651        let engine = GovernancePolicyEngine::with_defaults(
652            0,
653            parse_governance_xml(&gov_xml("SIGN")).unwrap(),
654        );
655        let peer: PeerKey = [9; 12];
656        let caps = PeerCapabilities {
657            auth_plugin_class: Some("DDS:Auth:PKI-DH:1.2".into()),
658            supported_suites: vec![SuiteHint::Aes128Gcm],
659            ..Default::default()
660        };
661        let iface = NetInterface::Wan;
662        let parts: Vec<String> = vec![];
663        let dec = engine.outbound_decision(mk_out_ctx(&peer, &caps, &iface, &parts));
664        assert_eq!(
665            dec.protection,
666            ProtectionLevel::Sign,
667            "without peer_classes the domain rule must take effect"
668        );
669    }
670}