Skip to main content

zerodds_security_permissions/
governance.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Governance-XML-Parser (OMG DDS-Security 1.1 §9.4.1.2).
5//!
6//! Governance-XML legt pro Domain fest, welche **Topic-Klassen** wie
7//! geschuetzt werden muessen (Discovery-Protection, Read-/Write-Access,
8//! Metadata-/Data-Protection-Kind). Die Datei wird typischerweise
9//! mit der Permissions-CA signiert und vom Access-Control-Plugin
10//! beim Participant-Start geladen.
11//!
12//! # Scope
13//!
14//! * Parser fuer `<domain_access_rules>` → `<domain_rule>` →
15//!   `<topic_access_rules>` → `<topic_rule>`.
16//! * Domain-Filter via `<domains><id>N</id></domains>` (einfaches
17//!   `id` oder `<id_range>min..max</id_range>`).
18//! * Topic-Expression-Matching mit Wildcards via [`crate::topic_match`].
19//! * Protection-Kinds fuer `metadata_protection_kind` und
20//!   `data_protection_kind`.
21//!
22//! # Nicht-Ziele
23//!
24//! * XML-Signatur-Verifikation — **future-major**.
25//! * `<allow_unauthenticated_participants>`-Enforcement — **future-major**.
26
27use alloc::collections::BTreeMap;
28use alloc::string::{String, ToString};
29use alloc::vec::Vec;
30
31use crate::delegation_check::{DelegationProfile, TrustAnchor, TrustPolicy};
32use zerodds_security_pki::SignatureAlgorithm;
33
34use crate::topic_match::topic_match;
35use crate::xml::PermissionsError;
36
37/// Topic-Protection-Kind (Spec §9.4.1.2 Tabelle 48).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum ProtectionKind {
40    /// Keine Schutzmassnahme.
41    #[default]
42    None,
43    /// Nur Integrity (HMAC / Signature).
44    Sign,
45    /// Integrity + Confidentiality (AEAD).
46    Encrypt,
47    /// Wie `Sign`, aber pro Remote-Reader eigene MAC.
48    SignWithOriginAuthentication,
49    /// Wie `Encrypt`, aber pro Remote-Reader eigene MAC.
50    EncryptWithOriginAuthentication,
51}
52
53impl ProtectionKind {
54    fn parse(s: &str) -> Self {
55        match s.trim().to_uppercase().as_str() {
56            "NONE" => Self::None,
57            "SIGN" => Self::Sign,
58            "ENCRYPT" => Self::Encrypt,
59            "SIGN_WITH_ORIGIN_AUTHENTICATION" => Self::SignWithOriginAuthentication,
60            "ENCRYPT_WITH_ORIGIN_AUTHENTICATION" => Self::EncryptWithOriginAuthentication,
61            _ => Self::None, // unbekannte → NONE (Fail-Open nur fuer
62                             // Development; Produktion validiert via
63                             // XML-Schema).
64        }
65    }
66}
67
68/// Regel fuer eine Topic-Klasse (oder Wildcard).
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct TopicRule {
71    /// Topic-Pattern (Wildcards `*` `?` wie in Permissions).
72    pub topic_expression: String,
73    /// Discovery-Schutz — SEDP wird verschluesselt.
74    pub enable_discovery_protection: bool,
75    /// Liveliness-Schutz — `PARTICIPANT_MESSAGE` signiert.
76    pub enable_liveliness_protection: bool,
77    /// Read-Access per Permissions pruefen.
78    pub enable_read_access_control: bool,
79    /// Write-Access per Permissions pruefen.
80    pub enable_write_access_control: bool,
81    /// SEC_PREFIX-Schutz fuer Submessage-Metadaten.
82    pub metadata_protection_kind: ProtectionKind,
83    /// SEC_BODY-Schutz fuer Payload-Daten.
84    pub data_protection_kind: ProtectionKind,
85}
86
87impl Default for TopicRule {
88    fn default() -> Self {
89        Self {
90            topic_expression: "*".into(),
91            enable_discovery_protection: false,
92            enable_liveliness_protection: false,
93            enable_read_access_control: false,
94            enable_write_access_control: false,
95            metadata_protection_kind: ProtectionKind::default(),
96            data_protection_kind: ProtectionKind::default(),
97        }
98    }
99}
100
101/// Domain-Filter: Liste von (min, max)-Ranges. Eine einzelne Id wird
102/// als `min == max` gespeichert.
103#[derive(Debug, Clone, PartialEq, Eq, Default)]
104pub struct DomainFilter {
105    /// Inklusive Ranges. Wenn leer: matcht alle Domains (Spec-Default).
106    pub ranges: Vec<(u32, u32)>,
107}
108
109impl DomainFilter {
110    /// `true` wenn `domain_id` in einem Range liegt oder die Filter-
111    /// Liste leer ist.
112    #[must_use]
113    pub fn matches(&self, domain_id: u32) -> bool {
114        if self.ranges.is_empty() {
115            return true;
116        }
117        self.ranges
118            .iter()
119            .any(|(lo, hi)| domain_id >= *lo && domain_id <= *hi)
120    }
121}
122
123/// Eine Domain-Regel im Governance-XML.
124#[derive(Debug, Clone, PartialEq, Eq, Default)]
125pub struct DomainRule {
126    /// Filter fuer Domain-IDs.
127    pub domains: DomainFilter,
128    /// Erlaubt unauthenticated Participants im Discovery. Default false.
129    pub allow_unauthenticated_participants: bool,
130    /// Pflicht-Access-Control auf Participant-Join.
131    pub enable_join_access_control: bool,
132    /// Discovery-Schutz auf Participant-Level.
133    pub discovery_protection_kind: ProtectionKind,
134    /// Liveliness-Schutz auf Participant-Level.
135    pub liveliness_protection_kind: ProtectionKind,
136    /// Signatur-Schutz fuer den RTPS-Header.
137    pub rtps_protection_kind: ProtectionKind,
138    /// Pro Topic-Klasse eine Regel.
139    pub topic_rules: Vec<TopicRule>,
140    /// ZeroDDS-Extension: Peer-Klassen fuer Heterogeneous-
141    /// Security. Leer bei reinen OMG-Governance-Dokumenten — das ist
142    /// der Legacy-Pfad. Namespace-scoped im XML:
143    /// `<zerodds:peer_classes>`.
144    pub peer_classes: Vec<PeerClass>,
145    /// ZeroDDS-Extension: pro Interface-Name eine Regel,
146    /// die Protection-Overrides und Peer-Class-Filter ausdrueckt.
147    /// Namespace-scoped: `<zerodds:interface_bindings>`.
148    pub interface_bindings: Vec<InterfaceBindingRule>,
149}
150
151/// Peer-Klasse aus `<zerodds:peer_class>` (RC1, Spec: Architektur-
152/// Doc §5).
153///
154/// Jeder Remote-Peer wird anhand seiner [`crate::PeerCapabilities`] +
155/// Cert-CN einer Peer-Klasse zugeordnet. Die erste matchende Klasse
156/// in [`DomainRule::peer_classes`] gewinnt — Reihenfolge im XML also
157/// relevant.
158#[derive(Debug, Clone, PartialEq, Eq, Default)]
159pub struct PeerClass {
160    /// Freier Name zum Diagnose-Zweck (z.B. `"legacy"`, `"fast"`,
161    /// `"secure"`, `"highassurance"`).
162    pub name: String,
163    /// Protection-Level, das fuer Peers dieser Klasse durchgesetzt
164    /// wird. Default `None`.
165    pub protection: ProtectionKind,
166    /// Match-Kriterien (wenn alle erfuellt, passt der Peer zu dieser
167    /// Klasse).
168    pub match_criteria: PeerClassMatch,
169}
170
171/// Match-Kriterien einer Peer-Klasse. Alle gesetzten Felder muessen
172/// erfuellt sein (UND-Verknuepfung). `None`/Default-Werte werden
173/// ignoriert.
174#[derive(Debug, Clone, PartialEq, Eq, Default)]
175pub struct PeerClassMatch {
176    /// Erwartete Auth-Plugin-Class (z.B. `"DDS:Auth:PKI-DH:1.2"`).
177    /// Der Leerstring `""` matcht explizit Peers **ohne** Plugin
178    /// (Legacy-Klassifikation). `None` = dieses Kriterium wird nicht
179    /// geprueft.
180    pub auth_plugin_class: Option<String>,
181    /// Wildcard-Pattern fuer den Cert-CN (`*` joker). Beispiel:
182    /// `"*.ha.example"` matcht `"writer1.ha.example"`.
183    pub cert_cn_pattern: Option<String>,
184    /// Suite-Anforderung. Der Peer muss diese Suite in seinen
185    /// `supported_suites` listen. Beispiel: `"AES_128_GCM"`.
186    pub suite: Option<String>,
187    /// OCSP-Live-Check-Flag — der Peer muss einen gueltigen Cert-
188    /// Status haben (spiegelt `has_valid_cert` in den Peer-Caps).
189    pub require_ocsp: bool,
190    /// Delegation-Profile-Referenz. Wenn gesetzt, MUSS
191    /// der Peer eine [`DelegationChain`](zerodds_security_pki::DelegationChain)
192    /// in seinen Capabilities haben, die gegen das Profil
193    /// validiert. `None` = direkter Auth-Pfad ohne Delegation.
194    pub delegation_profile: Option<String>,
195}
196
197/// Interface-spezifische Regel aus `<zerodds:interface_bindings>`
198///.
199///
200/// Bindet logische Interface-Namen an Protection-Overrides und
201/// zugelassene Peer-Klassen. Ergaenzt die socket-basierte Binding-
202/// Struktur aus Stufe 6, ohne sie zu ersetzen — der Governance-
203/// Eintrag ist die Policy-Sicht, das Socket-Binding ist die Transport-
204/// Sicht.
205#[derive(Debug, Clone, PartialEq, Eq, Default)]
206pub struct InterfaceBindingRule {
207    /// Name des Interfaces (muss mit `InterfaceBindingSpec::name` im
208    /// dcps-Runtime-Config uebereinstimmen).
209    pub name: String,
210    /// Ueberschreibt das Domain-Protection-Kind auf diesem Interface.
211    /// `None` = kein Override; Domain-Default gilt.
212    pub protection_override: Option<ProtectionKind>,
213    /// Zugelassene Peer-Klassen auf diesem Interface. Leer = keine
214    /// Einschraenkung (alle Klassen erlaubt).
215    pub peer_class_filter: Vec<String>,
216    /// Minimales Protection-Level auf diesem Interface. Ergebnis ist
217    /// `max(peer_class.protection, protection_min)`. `None` = kein
218    /// Minimum.
219    pub protection_min: Option<ProtectionKind>,
220}
221
222/// XML-Namespace-URI fuer ZeroDDS-Extensions in Governance.xml.
223pub const ZERODDS_NS: &str = "https://zerodds.org/schema/security/heterogeneous";
224
225/// Edge-Identity-Mode.
226///
227/// Architektur-Referenz: `09_delegation.md` §5 (Edge-Identities).
228/// `Static` = stabile GuidPrefix ueber Restart hinweg, manuell
229/// konfiguriert. `Ephemeral` = pseudozufaellige GuidPrefix mit
230/// Lifetime-Rotation, fuer Privacy-/Replay-Resistenz.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
232#[non_exhaustive]
233pub enum EdgeIdentityMode {
234    /// Stabiler Prefix, kein Auto-Rotate.
235    #[default]
236    Static,
237    /// Auto-Rotate nach `lifetime_seconds` ohne expliziten Trigger.
238    Ephemeral,
239}
240
241/// Edge-Identity-Konfiguration aus `<zerodds:edge_identities>`.
242///
243/// Pro Edge ein Eintrag mit Name, Mode, optional fixer GuidPrefix
244/// (12 byte; Static-Default), und Lifetime fuer Ephemeral-Rotation.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct EdgeIdentityConfig {
247    /// Logischer Edge-Name (z.B. `"lidar-A"`, `"turm-imu"`).
248    pub name: String,
249    /// Static oder Ephemeral.
250    pub mode: EdgeIdentityMode,
251    /// 12-byte GuidPrefix; Pflicht fuer `Static`, optional fuer
252    /// `Ephemeral` (initialer Wert).
253    pub guid_prefix: Option<[u8; 12]>,
254    /// Lifetime in Sekunden — nur `Ephemeral`. `None` = Default 300s.
255    pub lifetime_seconds: Option<u32>,
256}
257
258/// Default-Lifetime fuer Ephemeral-Edge-Identities (Sekunden).
259pub const DEFAULT_EPHEMERAL_LIFETIME_SECS: u32 = 300;
260
261impl EdgeIdentityConfig {
262    /// Effektive Lifetime in Sekunden — mit Default-Fallback.
263    #[must_use]
264    pub fn effective_lifetime(&self) -> u32 {
265        self.lifetime_seconds
266            .unwrap_or(DEFAULT_EPHEMERAL_LIFETIME_SECS)
267    }
268
269    /// True wenn der Mode `Ephemeral` ist.
270    #[must_use]
271    pub fn is_ephemeral(&self) -> bool {
272        matches!(self.mode, EdgeIdentityMode::Ephemeral)
273    }
274}
275
276/// Vollstaendige Governance-Config.
277#[derive(Debug, Clone, Default, PartialEq, Eq)]
278pub struct Governance {
279    /// Alle Domain-Regeln. Reihenfolge relevant (erster Match gewinnt).
280    pub domain_rules: Vec<DomainRule>,
281    /// Edge-Identity-Configs aus `<zerodds:edge_identities>`. Wird vom
282    /// GatewayBridge gelesen.
283    pub edge_identities: Vec<EdgeIdentityConfig>,
284    /// Delegation-Profiles aus `<zerodds:delegation_profiles>`.
285    /// Lookup per Profile-Name (Referenz aus
286    /// [`PeerClassMatch::delegation_profile`]).
287    pub delegation_profiles: BTreeMap<String, DelegationProfile>,
288}
289
290impl Governance {
291    /// Findet die passende [`DomainRule`] fuer eine Domain-ID. `None`
292    /// wenn keine matcht — Caller entscheidet Default-Policy.
293    #[must_use]
294    pub fn find_domain_rule(&self, domain_id: u32) -> Option<&DomainRule> {
295        self.domain_rules
296            .iter()
297            .find(|r| r.domains.matches(domain_id))
298    }
299
300    /// Findet die passende [`TopicRule`] innerhalb einer Domain-Regel.
301    /// Erster Match in `topic_rules` gewinnt; bei keiner Regel wird
302    /// `TopicRule::default()` (keine Protection) zurueckgegeben.
303    #[must_use]
304    pub fn find_topic_rule<'a>(
305        &'a self,
306        domain_id: u32,
307        topic_name: &str,
308    ) -> Option<&'a TopicRule> {
309        let dr = self.find_domain_rule(domain_id)?;
310        dr.topic_rules
311            .iter()
312            .find(|r| topic_match(&r.topic_expression, topic_name))
313    }
314}
315
316/// Parst ein Governance-XML-Dokument.
317///
318/// Akzeptiert das Spec-Schema aus §9.4.1.2:
319/// ```xml
320/// <dds>
321///   <domain_access_rules>
322///     <domain_rule>
323///       <domains><id>0</id></domains>
324///       <allow_unauthenticated_participants>FALSE</allow_unauthenticated_participants>
325///       <enable_join_access_control>TRUE</enable_join_access_control>
326///       <discovery_protection_kind>ENCRYPT</discovery_protection_kind>
327///       <liveliness_protection_kind>SIGN</liveliness_protection_kind>
328///       <rtps_protection_kind>NONE</rtps_protection_kind>
329///       <topic_access_rules>
330///         <topic_rule>
331///           <topic_expression>*</topic_expression>
332///           <enable_discovery_protection>TRUE</enable_discovery_protection>
333///           <enable_read_access_control>TRUE</enable_read_access_control>
334///           <enable_write_access_control>TRUE</enable_write_access_control>
335///           <metadata_protection_kind>SIGN</metadata_protection_kind>
336///           <data_protection_kind>ENCRYPT</data_protection_kind>
337///         </topic_rule>
338///       </topic_access_rules>
339///     </domain_rule>
340///   </domain_access_rules>
341/// </dds>
342/// ```
343///
344/// # Errors
345/// Siehe [`PermissionsError`] (wiederverwendet — Governance und
346/// Permissions teilen XML-Parse-Fehler-Klasse).
347pub fn parse_governance_xml(xml: &str) -> Result<Governance, PermissionsError> {
348    let doc =
349        roxmltree::Document::parse(xml).map_err(|e| PermissionsError::InvalidXml(e.to_string()))?;
350    let root = doc.root_element();
351    let mut rules = Vec::new();
352    walk_domain_rules(root, &mut rules)?;
353    let mut edge_identities = Vec::new();
354    walk_edge_identities(root, &mut edge_identities)?;
355    let mut delegation_profiles = BTreeMap::new();
356    walk_delegation_profiles(root, &mut delegation_profiles)?;
357    Ok(Governance {
358        domain_rules: rules,
359        edge_identities,
360        delegation_profiles,
361    })
362}
363
364// ============================================================================
365// RC1: Delegation-Profiles XML
366// ============================================================================
367
368/// zerodds-lint: recursion-depth = xml-tree-depth (≤ 16 in Praxis).
369fn walk_delegation_profiles(
370    node: roxmltree::Node<'_, '_>,
371    out: &mut BTreeMap<String, DelegationProfile>,
372) -> Result<(), PermissionsError> {
373    if node.tag_name().name() == "delegation_profiles"
374        && node.tag_name().namespace() == Some(ZERODDS_NS)
375    {
376        for child in node.children().filter(roxmltree::Node::is_element) {
377            if child.tag_name().name() == "profile"
378                && child.tag_name().namespace() == Some(ZERODDS_NS)
379            {
380                let p = parse_delegation_profile(child)?;
381                out.insert(p.name.clone(), p);
382            }
383        }
384        return Ok(());
385    }
386    for child in node.children().filter(roxmltree::Node::is_element) {
387        walk_delegation_profiles(child, out)?;
388    }
389    Ok(())
390}
391
392fn parse_delegation_profile(
393    node: roxmltree::Node<'_, '_>,
394) -> Result<DelegationProfile, PermissionsError> {
395    use alloc::collections::BTreeSet;
396    let name = node
397        .attribute("name")
398        .ok_or_else(|| PermissionsError::InvalidXml("<profile> missing name".into()))?
399        .to_string();
400    let mut trust_policy = TrustPolicy::DirectOrDelegated;
401    let mut max_chain_depth = 3usize;
402    let mut allowed_algorithms: BTreeSet<u8> = BTreeSet::new();
403    let mut trust_anchors: Vec<TrustAnchor> = Vec::new();
404    let mut require_ocsp = false;
405
406    for child in node.children().filter(roxmltree::Node::is_element) {
407        if child.tag_name().namespace() != Some(ZERODDS_NS) {
408            continue;
409        }
410        match child.tag_name().name() {
411            "trust_policy" => {
412                trust_policy = parse_trust_policy(child.text().unwrap_or("").trim())
413                    .unwrap_or(TrustPolicy::DirectOrDelegated);
414            }
415            "max_chain_depth" => {
416                if let Ok(v) = child.text().unwrap_or("").trim().parse::<usize>() {
417                    max_chain_depth = v;
418                }
419            }
420            "require_ocsp" => {
421                require_ocsp = parse_bool(child);
422            }
423            "allowed_algorithms" => {
424                for algo_el in child.children().filter(roxmltree::Node::is_element) {
425                    if algo_el.tag_name().name() == "algorithm"
426                        && algo_el.tag_name().namespace() == Some(ZERODDS_NS)
427                    {
428                        if let Some(a) = parse_algorithm(algo_el.text().unwrap_or("").trim()) {
429                            allowed_algorithms.insert(a.wire_id());
430                        }
431                    }
432                }
433            }
434            "trust_anchors" => {
435                for anchor_el in child.children().filter(roxmltree::Node::is_element) {
436                    if anchor_el.tag_name().name() == "anchor"
437                        && anchor_el.tag_name().namespace() == Some(ZERODDS_NS)
438                    {
439                        if let Some(a) = parse_trust_anchor(anchor_el)? {
440                            trust_anchors.push(a);
441                        }
442                    }
443                }
444            }
445            _ => {}
446        }
447    }
448
449    Ok(DelegationProfile {
450        name,
451        trust_policy,
452        trust_anchors,
453        max_chain_depth,
454        allowed_algorithms,
455        require_ocsp,
456    })
457}
458
459fn parse_trust_policy(s: &str) -> Option<TrustPolicy> {
460    match s.trim().to_lowercase().as_str() {
461        "gateway-only" | "gateway_only" => Some(TrustPolicy::GatewayOnly),
462        "direct-or-delegated" | "direct_or_delegated" => Some(TrustPolicy::DirectOrDelegated),
463        "federation" => Some(TrustPolicy::Federation),
464        "strict-delegated" | "strict_delegated" => Some(TrustPolicy::StrictDelegated),
465        _ => None,
466    }
467}
468
469fn parse_algorithm(s: &str) -> Option<SignatureAlgorithm> {
470    match s.trim().to_lowercase().as_str() {
471        "ecdsa-p256" | "ecdsa_p256" => Some(SignatureAlgorithm::EcdsaP256),
472        "ecdsa-p384" | "ecdsa_p384" => Some(SignatureAlgorithm::EcdsaP384),
473        "rsa-pss-2048" | "rsa_pss_2048" => Some(SignatureAlgorithm::RsaPss2048),
474        "ed25519" => Some(SignatureAlgorithm::Ed25519),
475        _ => None,
476    }
477}
478
479fn parse_trust_anchor(
480    node: roxmltree::Node<'_, '_>,
481) -> Result<Option<TrustAnchor>, PermissionsError> {
482    let subject_guid = match node
483        .attribute("subject_guid")
484        .and_then(parse_guid_prefix_hex_16)
485    {
486        Some(g) => g,
487        None => {
488            return Err(PermissionsError::InvalidXml(
489                "<anchor> needs valid 16-byte hex subject_guid".into(),
490            ));
491        }
492    };
493    let algorithm = node
494        .attribute("algorithm")
495        .and_then(parse_algorithm)
496        .ok_or_else(|| {
497            PermissionsError::InvalidXml("<anchor> needs valid algorithm attribute".into())
498        })?;
499    let pk_b64 = node
500        .attribute("public_key")
501        .ok_or_else(|| PermissionsError::InvalidXml("<anchor> needs public_key (base64)".into()))?;
502    let verify_public_key = base64_decode_anchor(pk_b64).ok_or_else(|| {
503        PermissionsError::InvalidXml("<anchor> public_key is not valid base64".into())
504    })?;
505    Ok(Some(TrustAnchor {
506        subject_guid,
507        verify_public_key,
508        algorithm,
509    }))
510}
511
512/// 16-byte (32-hex-char) GUID-Parser fuer Trust-Anchor.
513fn parse_guid_prefix_hex_16(s: &str) -> Option<[u8; 16]> {
514    let cleaned: String = s
515        .chars()
516        .filter(|c| !c.is_whitespace() && *c != ':' && *c != '-')
517        .collect();
518    if cleaned.len() != 32 {
519        return None;
520    }
521    let mut out = [0u8; 16];
522    for (i, byte_pair) in cleaned.as_bytes().chunks(2).enumerate() {
523        if i >= 16 {
524            return None;
525        }
526        let s = core::str::from_utf8(byte_pair).ok()?;
527        out[i] = u8::from_str_radix(s, 16).ok()?;
528    }
529    Some(out)
530}
531
532/// Base64-Decoder fuer Trust-Anchor-PubKey-Bytes.
533fn base64_decode_anchor(input: &str) -> Option<Vec<u8>> {
534    // Remove whitespace (PEM-style multiline).
535    let cleaned: String = input.chars().filter(|c| !c.is_whitespace()).collect();
536    let bytes = cleaned.as_bytes();
537    if bytes.len() % 4 != 0 {
538        return None;
539    }
540    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
541    for chunk in bytes.chunks_exact(4) {
542        let mut vals = [0u8; 4];
543        let mut pad = 0usize;
544        for (i, &c) in chunk.iter().enumerate() {
545            if c == b'=' {
546                pad += 1;
547                vals[i] = 0;
548            } else if pad > 0 {
549                return None;
550            } else {
551                vals[i] = match c {
552                    b'A'..=b'Z' => c - b'A',
553                    b'a'..=b'z' => c - b'a' + 26,
554                    b'0'..=b'9' => c - b'0' + 52,
555                    b'+' => 62,
556                    b'/' => 63,
557                    _ => return None,
558                };
559            }
560        }
561        let n = (u32::from(vals[0]) << 18)
562            | (u32::from(vals[1]) << 12)
563            | (u32::from(vals[2]) << 6)
564            | u32::from(vals[3]);
565        out.push(((n >> 16) & 0xFF) as u8);
566        if pad < 2 {
567            out.push(((n >> 8) & 0xFF) as u8);
568        }
569        if pad < 1 {
570            out.push((n & 0xFF) as u8);
571        }
572    }
573    Some(out)
574}
575
576/// Sucht rekursiv nach `<zerodds:edge_identities>`-Elementen und parst
577/// deren `<edge>`-Kinder.
578///
579/// zerodds-lint: recursion-depth = xml-tree-depth (≤ 16 in Praxis).
580fn walk_edge_identities(
581    node: roxmltree::Node<'_, '_>,
582    out: &mut Vec<EdgeIdentityConfig>,
583) -> Result<(), PermissionsError> {
584    if node.tag_name().name() == "edge_identities"
585        && node.tag_name().namespace() == Some(ZERODDS_NS)
586    {
587        let default_mode = parse_edge_mode_attr(node, "default_mode").unwrap_or_default();
588        for child in node.children().filter(roxmltree::Node::is_element) {
589            if child.tag_name().name() == "edge" && child.tag_name().namespace() == Some(ZERODDS_NS)
590            {
591                out.push(parse_edge(child, default_mode)?);
592            }
593        }
594        return Ok(());
595    }
596    for child in node.children().filter(roxmltree::Node::is_element) {
597        walk_edge_identities(child, out)?;
598    }
599    Ok(())
600}
601
602fn parse_edge_mode_attr(node: roxmltree::Node<'_, '_>, attr: &str) -> Option<EdgeIdentityMode> {
603    node.attribute(attr).and_then(|v| match v.trim() {
604        "static" => Some(EdgeIdentityMode::Static),
605        "ephemeral" => Some(EdgeIdentityMode::Ephemeral),
606        _ => None,
607    })
608}
609
610fn parse_edge(
611    node: roxmltree::Node<'_, '_>,
612    default_mode: EdgeIdentityMode,
613) -> Result<EdgeIdentityConfig, PermissionsError> {
614    let name = node
615        .attribute("name")
616        .ok_or_else(|| PermissionsError::InvalidXml("<edge> missing name attribute".into()))?
617        .to_string();
618    let mode = parse_edge_mode_attr(node, "mode").unwrap_or(default_mode);
619    let guid_prefix = node
620        .attribute("guid_prefix")
621        .and_then(parse_guid_prefix_hex);
622    let lifetime_seconds = node
623        .attribute("lifetime_seconds")
624        .and_then(|s| s.trim().parse::<u32>().ok());
625    Ok(EdgeIdentityConfig {
626        name,
627        mode,
628        guid_prefix,
629        lifetime_seconds,
630    })
631}
632
633/// Parst Hex-encoded 12-byte GuidPrefix mit optionalen Trennzeichen
634/// (`:`, `-`, whitespace). Beispiele:
635/// * `"01020304050607080910111213"`  (24 hex chars, 12 byte)
636/// * `"01:02:03:04:05:06:07:08:09:10:11:12"`
637fn parse_guid_prefix_hex(s: &str) -> Option<[u8; 12]> {
638    let cleaned: String = s
639        .chars()
640        .filter(|c| !c.is_whitespace() && *c != ':' && *c != '-')
641        .collect();
642    if cleaned.len() != 24 {
643        return None;
644    }
645    let mut out = [0u8; 12];
646    for (i, byte_pair) in cleaned.as_bytes().chunks(2).enumerate() {
647        if i >= 12 {
648            return None;
649        }
650        let s = core::str::from_utf8(byte_pair).ok()?;
651        out[i] = u8::from_str_radix(s, 16).ok()?;
652    }
653    Some(out)
654}
655
656/// zerodds-lint: recursion-depth = xml-tree-depth (≤ 16 in Praxis).
657fn walk_domain_rules(
658    node: roxmltree::Node<'_, '_>,
659    out: &mut Vec<DomainRule>,
660) -> Result<(), PermissionsError> {
661    if node.tag_name().name() == "domain_rule" {
662        out.push(parse_domain_rule(node)?);
663        return Ok(());
664    }
665    for child in node.children().filter(roxmltree::Node::is_element) {
666        walk_domain_rules(child, out)?;
667    }
668    Ok(())
669}
670
671fn parse_domain_rule(rule: roxmltree::Node<'_, '_>) -> Result<DomainRule, PermissionsError> {
672    let mut out = DomainRule::default();
673    for child in rule.children().filter(roxmltree::Node::is_element) {
674        match child.tag_name().name() {
675            "domains" => out.domains = parse_domain_filter(child),
676            "allow_unauthenticated_participants" => {
677                out.allow_unauthenticated_participants = parse_bool(child);
678            }
679            "enable_join_access_control" => {
680                out.enable_join_access_control = parse_bool(child);
681            }
682            "discovery_protection_kind" => {
683                if let Some(t) = child.text() {
684                    out.discovery_protection_kind = ProtectionKind::parse(t);
685                }
686            }
687            "liveliness_protection_kind" => {
688                if let Some(t) = child.text() {
689                    out.liveliness_protection_kind = ProtectionKind::parse(t);
690                }
691            }
692            "rtps_protection_kind" => {
693                if let Some(t) = child.text() {
694                    out.rtps_protection_kind = ProtectionKind::parse(t);
695                }
696            }
697            "topic_access_rules" => {
698                for tr in child.children().filter(|c| c.has_tag_name("topic_rule")) {
699                    out.topic_rules.push(parse_topic_rule(tr));
700                }
701            }
702            // RC1: zerodds-Extensions. Wir matchen uebers Namespace-
703            // URI — Elementname ist nur `peer_classes` ohne Praefix
704            // (roxmltree resolved das bereits).
705            "peer_classes" if child.tag_name().namespace() == Some(ZERODDS_NS) => {
706                for pc in child.children().filter(roxmltree::Node::is_element) {
707                    if pc.tag_name().name() == "peer_class"
708                        && pc.tag_name().namespace() == Some(ZERODDS_NS)
709                    {
710                        out.peer_classes.push(parse_peer_class(pc));
711                    }
712                }
713            }
714            "interface_bindings" if child.tag_name().namespace() == Some(ZERODDS_NS) => {
715                for ib in child.children().filter(roxmltree::Node::is_element) {
716                    if ib.tag_name().name() == "interface"
717                        && ib.tag_name().namespace() == Some(ZERODDS_NS)
718                    {
719                        out.interface_bindings.push(parse_interface_binding(ib));
720                    }
721                }
722            }
723            _ => {}
724        }
725    }
726    Ok(out)
727}
728
729fn parse_peer_class(node: roxmltree::Node<'_, '_>) -> PeerClass {
730    let mut out = PeerClass {
731        name: node.attribute("name").unwrap_or("").to_string(),
732        protection: node
733            .attribute("protection")
734            .map(ProtectionKind::parse)
735            .unwrap_or_default(),
736        match_criteria: PeerClassMatch::default(),
737    };
738    for child in node.children().filter(roxmltree::Node::is_element) {
739        // Wir akzeptieren `<match>` sowohl mit als auch ohne Namespace-
740        // Praefix — praktischer fuer XML-Autoren, die das Element
741        // innerhalb des parent-Namespace schreiben.
742        if child.tag_name().name() != "match" {
743            continue;
744        }
745        if let Some(v) = child.attribute("auth_plugin_class") {
746            out.match_criteria.auth_plugin_class = Some(v.to_string());
747        }
748        if let Some(v) = child.attribute("cert_cn_pattern") {
749            out.match_criteria.cert_cn_pattern = Some(v.to_string());
750        }
751        if let Some(v) = child.attribute("suite") {
752            out.match_criteria.suite = Some(v.to_string());
753        }
754        if let Some(v) = child.attribute("require_ocsp") {
755            out.match_criteria.require_ocsp =
756                matches!(v.trim().to_uppercase().as_str(), "TRUE" | "1" | "YES");
757        }
758    }
759    out
760}
761
762fn parse_interface_binding(node: roxmltree::Node<'_, '_>) -> InterfaceBindingRule {
763    let name = node.attribute("name").unwrap_or("").to_string();
764    let protection_override = node
765        .attribute("protection_override")
766        .map(ProtectionKind::parse);
767    let protection_min = node.attribute("protection_min").map(ProtectionKind::parse);
768    let peer_class_filter = node
769        .attribute("peer_class_filter")
770        .map(|s| {
771            s.split(',')
772                .map(|p| p.trim().to_string())
773                .filter(|p| !p.is_empty())
774                .collect()
775        })
776        .unwrap_or_default();
777    InterfaceBindingRule {
778        name,
779        protection_override,
780        peer_class_filter,
781        protection_min,
782    }
783}
784
785/// Wildcard-Matcher fuer Cert-CN-Patterns. Einziger Joker ist `*`,
786/// matcht beliebig viele Zeichen (inkl. `.`). Leeres Pattern matcht
787/// nur leere Strings. Fuer `*.fast.example` gilt:
788/// `"w1.fast.example"` → `true`, `"fast.example"` → `false`.
789#[must_use]
790pub fn cn_pattern_match(pattern: &str, cn: &str) -> bool {
791    // Splitten an `*`, dann iterativ im Haystack finden.
792    // Keine Regex-Dependency — das Projekt haelt den Safety-Crate-
793    // Footprint klein.
794    let parts: Vec<&str> = pattern.split('*').collect();
795    if parts.len() == 1 {
796        return pattern == cn;
797    }
798    let mut idx = 0usize;
799    // Prefix muss am Anfang passen.
800    if !parts[0].is_empty() {
801        if !cn.starts_with(parts[0]) {
802            return false;
803        }
804        idx = parts[0].len();
805    }
806    // Mittelstuecke finden.
807    for (i, p) in parts.iter().enumerate().skip(1) {
808        if p.is_empty() {
809            // Zwei `*` hintereinander → leeres Mittelstueck; skip.
810            continue;
811        }
812        let is_last = i == parts.len() - 1;
813        if is_last {
814            // Letztes Stueck muss am Ende stehen.
815            if !cn[idx..].ends_with(p) {
816                return false;
817            }
818            // Und darf nicht ueberlappen mit bereits matchten Bytes.
819            let need = idx + p.len();
820            if cn.len() < need {
821                return false;
822            }
823            return true;
824        }
825        match cn[idx..].find(p) {
826            Some(found) => idx += found + p.len(),
827            None => return false,
828        }
829    }
830    true
831}
832
833fn parse_domain_filter(node: roxmltree::Node<'_, '_>) -> DomainFilter {
834    let mut ranges = Vec::new();
835    for child in node.children().filter(roxmltree::Node::is_element) {
836        match child.tag_name().name() {
837            "id" => {
838                if let Some(t) = child.text() {
839                    if let Ok(n) = t.trim().parse::<u32>() {
840                        ranges.push((n, n));
841                    }
842                }
843            }
844            "id_range" => {
845                let lo = child
846                    .children()
847                    .find(|c| c.has_tag_name("min"))
848                    .and_then(|c| c.text())
849                    .and_then(|t| t.trim().parse::<u32>().ok())
850                    .unwrap_or(0);
851                let hi = child
852                    .children()
853                    .find(|c| c.has_tag_name("max"))
854                    .and_then(|c| c.text())
855                    .and_then(|t| t.trim().parse::<u32>().ok())
856                    .unwrap_or(u32::MAX);
857                ranges.push((lo, hi));
858            }
859            _ => {}
860        }
861    }
862    DomainFilter { ranges }
863}
864
865fn parse_topic_rule(node: roxmltree::Node<'_, '_>) -> TopicRule {
866    let mut out = TopicRule::default();
867    for child in node.children().filter(roxmltree::Node::is_element) {
868        match child.tag_name().name() {
869            "topic_expression" => {
870                if let Some(t) = child.text() {
871                    out.topic_expression = t.trim().to_string();
872                }
873            }
874            "enable_discovery_protection" => out.enable_discovery_protection = parse_bool(child),
875            "enable_liveliness_protection" => out.enable_liveliness_protection = parse_bool(child),
876            "enable_read_access_control" => out.enable_read_access_control = parse_bool(child),
877            "enable_write_access_control" => out.enable_write_access_control = parse_bool(child),
878            "metadata_protection_kind" => {
879                if let Some(t) = child.text() {
880                    out.metadata_protection_kind = ProtectionKind::parse(t);
881                }
882            }
883            "data_protection_kind" => {
884                if let Some(t) = child.text() {
885                    out.data_protection_kind = ProtectionKind::parse(t);
886                }
887            }
888            _ => {}
889        }
890    }
891    out
892}
893
894fn parse_bool(node: roxmltree::Node<'_, '_>) -> bool {
895    node.text()
896        .map(|t| {
897            let up = t.trim().to_uppercase();
898            up == "TRUE" || up == "1" || up == "YES"
899        })
900        .unwrap_or(false)
901}
902
903#[cfg(test)]
904#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
905mod tests {
906    use super::*;
907
908    const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
909<dds>
910  <domain_access_rules>
911    <domain_rule>
912      <domains>
913        <id>0</id>
914        <id_range><min>10</min><max>20</max></id_range>
915      </domains>
916      <allow_unauthenticated_participants>FALSE</allow_unauthenticated_participants>
917      <enable_join_access_control>TRUE</enable_join_access_control>
918      <discovery_protection_kind>ENCRYPT</discovery_protection_kind>
919      <liveliness_protection_kind>SIGN</liveliness_protection_kind>
920      <rtps_protection_kind>NONE</rtps_protection_kind>
921      <topic_access_rules>
922        <topic_rule>
923          <topic_expression>Chatter</topic_expression>
924          <enable_discovery_protection>TRUE</enable_discovery_protection>
925          <enable_read_access_control>TRUE</enable_read_access_control>
926          <enable_write_access_control>TRUE</enable_write_access_control>
927          <metadata_protection_kind>SIGN</metadata_protection_kind>
928          <data_protection_kind>ENCRYPT</data_protection_kind>
929        </topic_rule>
930        <topic_rule>
931          <topic_expression>*</topic_expression>
932          <metadata_protection_kind>NONE</metadata_protection_kind>
933          <data_protection_kind>NONE</data_protection_kind>
934        </topic_rule>
935      </topic_access_rules>
936    </domain_rule>
937  </domain_access_rules>
938</dds>
939"#;
940
941    #[test]
942    fn parses_domain_rule_with_ranges() {
943        let g = parse_governance_xml(SAMPLE).expect("parse");
944        assert_eq!(g.domain_rules.len(), 1);
945        let d = &g.domain_rules[0];
946        assert!(!d.allow_unauthenticated_participants);
947        assert!(d.enable_join_access_control);
948        assert_eq!(d.discovery_protection_kind, ProtectionKind::Encrypt);
949        assert_eq!(d.rtps_protection_kind, ProtectionKind::None);
950        assert_eq!(d.domains.ranges, vec![(0, 0), (10, 20)]);
951    }
952
953    #[test]
954    fn topic_rule_matches_exact_topic_first() {
955        let g = parse_governance_xml(SAMPLE).unwrap();
956        let tr = g.find_topic_rule(0, "Chatter").expect("rule");
957        assert_eq!(tr.metadata_protection_kind, ProtectionKind::Sign);
958        assert_eq!(tr.data_protection_kind, ProtectionKind::Encrypt);
959    }
960
961    #[test]
962    fn topic_rule_falls_through_to_wildcard() {
963        let g = parse_governance_xml(SAMPLE).unwrap();
964        let tr = g.find_topic_rule(0, "UnknownTopic").expect("wildcard");
965        assert_eq!(tr.metadata_protection_kind, ProtectionKind::None);
966    }
967
968    #[test]
969    fn domain_filter_id_range_matches_inclusive() {
970        let g = parse_governance_xml(SAMPLE).unwrap();
971        assert!(g.find_domain_rule(10).is_some());
972        assert!(g.find_domain_rule(15).is_some());
973        assert!(g.find_domain_rule(20).is_some());
974        // 21 liegt ausserhalb aller Ranges (0-0, 10-20), also None.
975        assert!(g.find_domain_rule(21).is_none());
976    }
977
978    #[test]
979    fn empty_domains_matches_all() {
980        let xml = r#"
981<domain_access_rules>
982  <domain_rule>
983    <domains/>
984    <topic_access_rules>
985      <topic_rule><topic_expression>*</topic_expression></topic_rule>
986    </topic_access_rules>
987  </domain_rule>
988</domain_access_rules>"#;
989        let g = parse_governance_xml(xml).unwrap();
990        assert!(g.find_domain_rule(42).is_some());
991    }
992
993    #[test]
994    fn rejects_invalid_xml() {
995        assert!(matches!(
996            parse_governance_xml("<not-closed"),
997            Err(PermissionsError::InvalidXml(_))
998        ));
999    }
1000
1001    #[test]
1002    fn protection_kind_parses_case_insensitive() {
1003        assert_eq!(ProtectionKind::parse("encrypt"), ProtectionKind::Encrypt);
1004        assert_eq!(ProtectionKind::parse("Sign"), ProtectionKind::Sign);
1005        assert_eq!(ProtectionKind::parse("NONE"), ProtectionKind::None);
1006        assert_eq!(
1007            ProtectionKind::parse("encrypt_with_origin_authentication"),
1008            ProtectionKind::EncryptWithOriginAuthentication
1009        );
1010    }
1011
1012    // =======================================================================
1013    // RC1 Stufe 8 — Peer-Classes + Interface-Bindings (zerodds-ns)
1014    // =======================================================================
1015
1016    const HETERO_GOV: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1017<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1018  <domain_access_rules>
1019    <domain_rule>
1020      <domains><id>0</id></domains>
1021      <rtps_protection_kind>SIGN</rtps_protection_kind>
1022
1023      <zerodds:peer_classes>
1024        <zerodds:peer_class name="legacy" protection="NONE">
1025          <zerodds:match auth_plugin_class="" />
1026        </zerodds:peer_class>
1027        <zerodds:peer_class name="fast" protection="SIGN">
1028          <zerodds:match cert_cn_pattern="*.fast.example" />
1029        </zerodds:peer_class>
1030        <zerodds:peer_class name="secure" protection="ENCRYPT">
1031          <zerodds:match auth_plugin_class="DDS:Auth:PKI-DH:1.2" suite="AES_128_GCM" />
1032        </zerodds:peer_class>
1033        <zerodds:peer_class name="highassurance" protection="ENCRYPT">
1034          <zerodds:match cert_cn_pattern="*.ha.*" suite="AES_256_GCM" require_ocsp="TRUE" />
1035        </zerodds:peer_class>
1036      </zerodds:peer_classes>
1037
1038      <zerodds:interface_bindings>
1039        <zerodds:interface name="loopback" protection_override="NONE" />
1040        <zerodds:interface name="shm"      protection_override="NONE" />
1041        <zerodds:interface name="eth0"     peer_class_filter="legacy,fast,secure" />
1042        <zerodds:interface name="tun0"     peer_class_filter="secure,highassurance"
1043                                           protection_min="ENCRYPT" />
1044      </zerodds:interface_bindings>
1045    </domain_rule>
1046  </domain_access_rules>
1047</dds>"#;
1048
1049    // ---- cn_pattern_match ----
1050
1051    #[test]
1052    fn cn_pattern_exact_match_no_wildcard() {
1053        assert!(cn_pattern_match("alice.example", "alice.example"));
1054        assert!(!cn_pattern_match("alice.example", "bob.example"));
1055    }
1056
1057    #[test]
1058    fn cn_pattern_leading_star_matches_suffix() {
1059        assert!(cn_pattern_match("*.fast.example", "writer1.fast.example"));
1060        assert!(cn_pattern_match("*.fast.example", "x.fast.example"));
1061        assert!(!cn_pattern_match("*.fast.example", "fast.example"));
1062        assert!(!cn_pattern_match("*.fast.example", "slow.example"));
1063    }
1064
1065    #[test]
1066    fn cn_pattern_trailing_star_matches_prefix() {
1067        assert!(cn_pattern_match("writer*", "writer1"));
1068        assert!(cn_pattern_match("writer*", "writer.ha.domain"));
1069        assert!(!cn_pattern_match("writer*", "reader1"));
1070    }
1071
1072    #[test]
1073    fn cn_pattern_middle_star_matches_infix() {
1074        assert!(cn_pattern_match("*.ha.*", "w1.ha.internal"));
1075        assert!(cn_pattern_match("*.ha.*", "reader.ha.corp.local"));
1076        assert!(!cn_pattern_match("*.ha.*", "w1.fast.example"));
1077    }
1078
1079    #[test]
1080    fn cn_pattern_only_star_matches_any() {
1081        assert!(cn_pattern_match("*", "anything"));
1082        assert!(cn_pattern_match("*", ""));
1083    }
1084
1085    #[test]
1086    fn cn_pattern_empty_matches_only_empty() {
1087        assert!(cn_pattern_match("", ""));
1088        assert!(!cn_pattern_match("", "non-empty"));
1089    }
1090
1091    // ---- Peer-Classes XML-Parse ----
1092
1093    #[test]
1094    fn hetero_gov_parses_four_peer_classes_in_order() {
1095        let g = parse_governance_xml(HETERO_GOV).unwrap();
1096        let rule = g.find_domain_rule(0).unwrap();
1097        assert_eq!(rule.peer_classes.len(), 4);
1098        assert_eq!(rule.peer_classes[0].name, "legacy");
1099        assert_eq!(rule.peer_classes[1].name, "fast");
1100        assert_eq!(rule.peer_classes[2].name, "secure");
1101        assert_eq!(rule.peer_classes[3].name, "highassurance");
1102    }
1103
1104    #[test]
1105    fn hetero_gov_peer_class_protection_levels_correct() {
1106        let g = parse_governance_xml(HETERO_GOV).unwrap();
1107        let rule = g.find_domain_rule(0).unwrap();
1108        assert_eq!(rule.peer_classes[0].protection, ProtectionKind::None);
1109        assert_eq!(rule.peer_classes[1].protection, ProtectionKind::Sign);
1110        assert_eq!(rule.peer_classes[2].protection, ProtectionKind::Encrypt);
1111        assert_eq!(rule.peer_classes[3].protection, ProtectionKind::Encrypt);
1112    }
1113
1114    #[test]
1115    fn hetero_gov_peer_class_match_criteria_parsed() {
1116        let g = parse_governance_xml(HETERO_GOV).unwrap();
1117        let rule = g.find_domain_rule(0).unwrap();
1118
1119        // Legacy: explicit empty auth_plugin = "no plugin expected"
1120        assert_eq!(
1121            rule.peer_classes[0]
1122                .match_criteria
1123                .auth_plugin_class
1124                .as_deref(),
1125            Some("")
1126        );
1127
1128        // Fast: cert-CN-Pattern
1129        assert_eq!(
1130            rule.peer_classes[1]
1131                .match_criteria
1132                .cert_cn_pattern
1133                .as_deref(),
1134            Some("*.fast.example")
1135        );
1136
1137        // Secure: auth + suite
1138        assert_eq!(
1139            rule.peer_classes[2]
1140                .match_criteria
1141                .auth_plugin_class
1142                .as_deref(),
1143            Some("DDS:Auth:PKI-DH:1.2")
1144        );
1145        assert_eq!(
1146            rule.peer_classes[2].match_criteria.suite.as_deref(),
1147            Some("AES_128_GCM")
1148        );
1149
1150        // HA: cert + suite + ocsp
1151        assert_eq!(
1152            rule.peer_classes[3]
1153                .match_criteria
1154                .cert_cn_pattern
1155                .as_deref(),
1156            Some("*.ha.*")
1157        );
1158        assert_eq!(
1159            rule.peer_classes[3].match_criteria.suite.as_deref(),
1160            Some("AES_256_GCM")
1161        );
1162        assert!(rule.peer_classes[3].match_criteria.require_ocsp);
1163    }
1164
1165    // ---- Interface-Bindings XML-Parse ----
1166
1167    #[test]
1168    fn hetero_gov_interface_bindings_parsed() {
1169        let g = parse_governance_xml(HETERO_GOV).unwrap();
1170        let rule = g.find_domain_rule(0).unwrap();
1171        assert_eq!(rule.interface_bindings.len(), 4);
1172
1173        let lo = &rule.interface_bindings[0];
1174        assert_eq!(lo.name, "loopback");
1175        assert_eq!(lo.protection_override, Some(ProtectionKind::None));
1176
1177        let eth0 = &rule.interface_bindings[2];
1178        assert_eq!(eth0.name, "eth0");
1179        assert_eq!(
1180            eth0.peer_class_filter,
1181            vec![
1182                "legacy".to_string(),
1183                "fast".to_string(),
1184                "secure".to_string()
1185            ]
1186        );
1187
1188        let tun0 = &rule.interface_bindings[3];
1189        assert_eq!(tun0.name, "tun0");
1190        assert_eq!(tun0.protection_min, Some(ProtectionKind::Encrypt));
1191        assert_eq!(
1192            tun0.peer_class_filter,
1193            vec!["secure".to_string(), "highassurance".to_string()]
1194        );
1195    }
1196
1197    // ---- OMG-Vendor-Interop ----
1198
1199    #[test]
1200    fn pure_omg_governance_yields_empty_peer_classes_and_bindings() {
1201        // Ein Governance-Dokument ohne zerodds:-Namespace soll exakt
1202        // wie heute arbeiten (Abwaerts-Kompatibilitaet).
1203        let g = parse_governance_xml(SAMPLE).unwrap();
1204        for rule in &g.domain_rules {
1205            assert!(
1206                rule.peer_classes.is_empty(),
1207                "OMG-only-Doc darf keine peer_classes triggern"
1208            );
1209            assert!(
1210                rule.interface_bindings.is_empty(),
1211                "OMG-only-Doc darf keine interface_bindings triggern"
1212            );
1213        }
1214    }
1215
1216    #[test]
1217    fn cyclone_style_without_namespace_declaration_ignores_zerodds_elements() {
1218        // Cyclone-Perspektive: sie parsen Governance-XML und werfen
1219        // unbekannte Namespaces weg. Wir simulieren das indem ein XML
1220        // die zerodds-Elemente ohne Namespace-Deklaration benutzt —
1221        // dann matched unser Namespace-Filter nicht und das Element
1222        // wird still ignoriert.
1223        //
1224        // Das ist die Vendor-Interop-Garantie: Cyclone/FastDDS sehen
1225        // zerodds:-Tags, ignorieren sie wenn sie den Namespace nicht
1226        // kennen, und fallen auf rtps_protection_kind zurueck.
1227        const MIXED: &str = r#"<?xml version="1.0"?>
1228<dds>
1229  <domain_access_rules>
1230    <domain_rule>
1231      <domains><id>0</id></domains>
1232      <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
1233      <peer_classes>
1234        <peer_class name="should-be-ignored" protection="NONE" />
1235      </peer_classes>
1236    </domain_rule>
1237  </domain_access_rules>
1238</dds>"#;
1239        let g = parse_governance_xml(MIXED).unwrap();
1240        let rule = g.find_domain_rule(0).unwrap();
1241        assert!(
1242            rule.peer_classes.is_empty(),
1243            "peer_classes ohne zerodds-namespace muss ignoriert werden"
1244        );
1245        assert_eq!(rule.rtps_protection_kind, ProtectionKind::Encrypt);
1246    }
1247
1248    // ========================================================================
1249    // RC1: Edge-Identities XML
1250    // ========================================================================
1251
1252    #[test]
1253    fn edge_identity_default_mode_is_static() {
1254        let cfg = EdgeIdentityConfig {
1255            name: "x".into(),
1256            mode: EdgeIdentityMode::default(),
1257            guid_prefix: None,
1258            lifetime_seconds: None,
1259        };
1260        assert_eq!(cfg.mode, EdgeIdentityMode::Static);
1261        assert_eq!(cfg.effective_lifetime(), 300);
1262        assert!(!cfg.is_ephemeral());
1263    }
1264
1265    #[test]
1266    fn edge_identity_ephemeral_with_explicit_lifetime() {
1267        let cfg = EdgeIdentityConfig {
1268            name: "imu".into(),
1269            mode: EdgeIdentityMode::Ephemeral,
1270            guid_prefix: None,
1271            lifetime_seconds: Some(60),
1272        };
1273        assert!(cfg.is_ephemeral());
1274        assert_eq!(cfg.effective_lifetime(), 60);
1275    }
1276
1277    #[test]
1278    fn parses_edge_identities_block_with_two_edges() {
1279        const XML: &str = r#"<?xml version="1.0"?>
1280<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1281  <domain_access_rules>
1282    <domain_rule>
1283      <domains><id>0</id></domains>
1284      <rtps_protection_kind>ENCRYPT</rtps_protection_kind>
1285    </domain_rule>
1286  </domain_access_rules>
1287  <zerodds:edge_identities default_mode="static">
1288    <zerodds:edge name="lidar-A" guid_prefix="010203040506070809101112" />
1289    <zerodds:edge name="turm-imu" mode="ephemeral" lifetime_seconds="60" />
1290  </zerodds:edge_identities>
1291</dds>"#;
1292        let g = parse_governance_xml(XML).unwrap();
1293        assert_eq!(g.edge_identities.len(), 2);
1294
1295        let lidar = &g.edge_identities[0];
1296        assert_eq!(lidar.name, "lidar-A");
1297        assert_eq!(lidar.mode, EdgeIdentityMode::Static);
1298        assert_eq!(
1299            lidar.guid_prefix,
1300            Some([
1301                0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x10, 0x11, 0x12
1302            ])
1303        );
1304
1305        let imu = &g.edge_identities[1];
1306        assert_eq!(imu.name, "turm-imu");
1307        assert_eq!(imu.mode, EdgeIdentityMode::Ephemeral);
1308        assert_eq!(imu.lifetime_seconds, Some(60));
1309    }
1310
1311    #[test]
1312    fn edge_identity_inherits_default_mode() {
1313        const XML: &str = r#"<?xml version="1.0"?>
1314<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1315  <zerodds:edge_identities default_mode="ephemeral">
1316    <zerodds:edge name="auto-rotated" />
1317  </zerodds:edge_identities>
1318</dds>"#;
1319        let g = parse_governance_xml(XML).unwrap();
1320        assert_eq!(g.edge_identities[0].mode, EdgeIdentityMode::Ephemeral);
1321    }
1322
1323    #[test]
1324    fn edge_identity_with_colon_separated_guid() {
1325        const XML: &str = r#"<?xml version="1.0"?>
1326<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1327  <zerodds:edge_identities>
1328    <zerodds:edge name="ecu-a" guid_prefix="aa:bb:cc:dd:ee:ff:11:22:33:44:55:66" />
1329  </zerodds:edge_identities>
1330</dds>"#;
1331        let g = parse_governance_xml(XML).unwrap();
1332        let p = g.edge_identities[0].guid_prefix.unwrap();
1333        assert_eq!(
1334            p,
1335            [
1336                0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66
1337            ]
1338        );
1339    }
1340
1341    #[test]
1342    fn edge_identity_invalid_guid_is_none() {
1343        const XML: &str = r#"<?xml version="1.0"?>
1344<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1345  <zerodds:edge_identities>
1346    <zerodds:edge name="bad" guid_prefix="ZZ" />
1347  </zerodds:edge_identities>
1348</dds>"#;
1349        let g = parse_governance_xml(XML).unwrap();
1350        assert!(g.edge_identities[0].guid_prefix.is_none());
1351    }
1352
1353    #[test]
1354    fn edge_identity_without_namespace_is_ignored() {
1355        // Ohne zerodds:-Namespace darf nichts geparst werden — ZeroDDS-
1356        // Extension-Pflicht.
1357        const XML: &str = r#"<?xml version="1.0"?>
1358<dds>
1359  <edge_identities>
1360    <edge name="ignored-no-ns" />
1361  </edge_identities>
1362</dds>"#;
1363        let g = parse_governance_xml(XML).unwrap();
1364        assert!(g.edge_identities.is_empty());
1365    }
1366
1367    // ========================================================================
1368    // RC1: Delegation-Profile XML
1369    // ========================================================================
1370
1371    /// Test-Helper — generiert ein PubKey im richtigen Format und encodiert
1372    /// es als Base64.
1373    fn ecdsa_p256_test_pubkey_base64() -> String {
1374        use ring::rand::SystemRandom;
1375        use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair};
1376        let rng = SystemRandom::new();
1377        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
1378        let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
1379            .unwrap();
1380        let raw = kp.public_key().as_ref();
1381        // Base64-encode (standard alphabet, with padding).
1382        let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1383        let mut out = String::new();
1384        let mut chunks = raw.chunks_exact(3);
1385        for chunk in &mut chunks {
1386            let n = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]);
1387            out.push(alphabet[((n >> 18) & 0x3F) as usize] as char);
1388            out.push(alphabet[((n >> 12) & 0x3F) as usize] as char);
1389            out.push(alphabet[((n >> 6) & 0x3F) as usize] as char);
1390            out.push(alphabet[(n & 0x3F) as usize] as char);
1391        }
1392        let rem = chunks.remainder();
1393        match rem.len() {
1394            1 => {
1395                let n = u32::from(rem[0]) << 16;
1396                out.push(alphabet[((n >> 18) & 0x3F) as usize] as char);
1397                out.push(alphabet[((n >> 12) & 0x3F) as usize] as char);
1398                out.push('=');
1399                out.push('=');
1400            }
1401            2 => {
1402                let n = (u32::from(rem[0]) << 16) | (u32::from(rem[1]) << 8);
1403                out.push(alphabet[((n >> 18) & 0x3F) as usize] as char);
1404                out.push(alphabet[((n >> 12) & 0x3F) as usize] as char);
1405                out.push(alphabet[((n >> 6) & 0x3F) as usize] as char);
1406                out.push('=');
1407            }
1408            _ => {}
1409        }
1410        out
1411    }
1412
1413    #[test]
1414    fn parses_single_delegation_profile() {
1415        let pk_b64 = ecdsa_p256_test_pubkey_base64();
1416        let xml = alloc::format!(
1417            r#"<?xml version="1.0"?>
1418<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1419  <zerodds:delegation_profiles>
1420    <zerodds:profile name="vehicle-internal">
1421      <zerodds:trust_policy>direct-or-delegated</zerodds:trust_policy>
1422      <zerodds:max_chain_depth>3</zerodds:max_chain_depth>
1423      <zerodds:require_ocsp>false</zerodds:require_ocsp>
1424      <zerodds:allowed_algorithms>
1425        <zerodds:algorithm>ecdsa-p256</zerodds:algorithm>
1426        <zerodds:algorithm>ed25519</zerodds:algorithm>
1427      </zerodds:allowed_algorithms>
1428      <zerodds:trust_anchors>
1429        <zerodds:anchor subject_guid="01020304050607080910111213141516"
1430                        algorithm="ecdsa-p256"
1431                        public_key="{pk_b64}" />
1432      </zerodds:trust_anchors>
1433    </zerodds:profile>
1434  </zerodds:delegation_profiles>
1435</dds>"#
1436        );
1437        let g = parse_governance_xml(&xml).unwrap();
1438        assert_eq!(g.delegation_profiles.len(), 1);
1439        let p = g.delegation_profiles.get("vehicle-internal").unwrap();
1440        assert_eq!(p.name, "vehicle-internal");
1441        assert!(matches!(p.trust_policy, TrustPolicy::DirectOrDelegated));
1442        assert_eq!(p.max_chain_depth, 3);
1443        assert!(!p.require_ocsp);
1444        assert!(
1445            p.allowed_algorithms
1446                .contains(&SignatureAlgorithm::EcdsaP256.wire_id())
1447        );
1448        assert!(
1449            p.allowed_algorithms
1450                .contains(&SignatureAlgorithm::Ed25519.wire_id())
1451        );
1452        assert_eq!(p.trust_anchors.len(), 1);
1453        let a = &p.trust_anchors[0];
1454        assert_eq!(a.subject_guid[0], 0x01);
1455        assert_eq!(a.subject_guid[15], 0x16);
1456        assert!(matches!(a.algorithm, SignatureAlgorithm::EcdsaP256));
1457    }
1458
1459    #[test]
1460    fn parses_all_four_trust_policies() {
1461        for (xml_val, expected) in [
1462            ("gateway-only", TrustPolicy::GatewayOnly),
1463            ("direct-or-delegated", TrustPolicy::DirectOrDelegated),
1464            ("federation", TrustPolicy::Federation),
1465            ("strict-delegated", TrustPolicy::StrictDelegated),
1466        ] {
1467            assert_eq!(parse_trust_policy(xml_val), Some(expected));
1468        }
1469        assert!(parse_trust_policy("unknown").is_none());
1470    }
1471
1472    #[test]
1473    fn parses_all_four_algorithms() {
1474        assert_eq!(
1475            parse_algorithm("ecdsa-p256"),
1476            Some(SignatureAlgorithm::EcdsaP256)
1477        );
1478        assert_eq!(
1479            parse_algorithm("ECDSA-P384"),
1480            Some(SignatureAlgorithm::EcdsaP384)
1481        );
1482        assert_eq!(
1483            parse_algorithm("rsa-pss-2048"),
1484            Some(SignatureAlgorithm::RsaPss2048)
1485        );
1486        assert_eq!(
1487            parse_algorithm("ed25519"),
1488            Some(SignatureAlgorithm::Ed25519)
1489        );
1490        assert!(parse_algorithm("xyz").is_none());
1491    }
1492
1493    #[test]
1494    fn unknown_trust_policy_falls_back_to_default() {
1495        let pk = ecdsa_p256_test_pubkey_base64();
1496        let xml = alloc::format!(
1497            r#"<?xml version="1.0"?>
1498<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1499  <zerodds:delegation_profiles>
1500    <zerodds:profile name="bad">
1501      <zerodds:trust_policy>nonsense-mode</zerodds:trust_policy>
1502      <zerodds:trust_anchors>
1503        <zerodds:anchor subject_guid="01020304050607080910111213141516"
1504                        algorithm="ecdsa-p256"
1505                        public_key="{pk}" />
1506      </zerodds:trust_anchors>
1507    </zerodds:profile>
1508  </zerodds:delegation_profiles>
1509</dds>"#
1510        );
1511        let g = parse_governance_xml(&xml).unwrap();
1512        let p = g.delegation_profiles.get("bad").unwrap();
1513        // Default = DirectOrDelegated wenn Wert nicht parsebar.
1514        assert!(matches!(p.trust_policy, TrustPolicy::DirectOrDelegated));
1515    }
1516
1517    #[test]
1518    fn anchor_with_invalid_guid_is_error() {
1519        let pk = ecdsa_p256_test_pubkey_base64();
1520        let xml = alloc::format!(
1521            r#"<?xml version="1.0"?>
1522<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1523  <zerodds:delegation_profiles>
1524    <zerodds:profile name="bad">
1525      <zerodds:trust_anchors>
1526        <zerodds:anchor subject_guid="ZZ"
1527                        algorithm="ecdsa-p256"
1528                        public_key="{pk}" />
1529      </zerodds:trust_anchors>
1530    </zerodds:profile>
1531  </zerodds:delegation_profiles>
1532</dds>"#
1533        );
1534        let err = parse_governance_xml(&xml).expect_err("must fail");
1535        assert!(matches!(err, PermissionsError::InvalidXml(_)));
1536    }
1537
1538    #[test]
1539    fn anchor_without_public_key_is_error() {
1540        const XML: &str = r#"<?xml version="1.0"?>
1541<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1542  <zerodds:delegation_profiles>
1543    <zerodds:profile name="bad">
1544      <zerodds:trust_anchors>
1545        <zerodds:anchor subject_guid="01020304050607080910111213141516"
1546                        algorithm="ecdsa-p256" />
1547      </zerodds:trust_anchors>
1548    </zerodds:profile>
1549  </zerodds:delegation_profiles>
1550</dds>"#;
1551        let err = parse_governance_xml(XML).expect_err("must fail");
1552        assert!(matches!(err, PermissionsError::InvalidXml(_)));
1553    }
1554
1555    #[test]
1556    fn delegation_profile_without_namespace_is_ignored() {
1557        const XML: &str = r#"<?xml version="1.0"?>
1558<dds>
1559  <delegation_profiles>
1560    <profile name="ignored" />
1561  </delegation_profiles>
1562</dds>"#;
1563        let g = parse_governance_xml(XML).unwrap();
1564        assert!(g.delegation_profiles.is_empty());
1565    }
1566
1567    #[test]
1568    fn profile_without_name_is_error() {
1569        const XML: &str = r#"<?xml version="1.0"?>
1570<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1571  <zerodds:delegation_profiles>
1572    <zerodds:profile />
1573  </zerodds:delegation_profiles>
1574</dds>"#;
1575        let err = parse_governance_xml(XML).expect_err("must fail");
1576        assert!(matches!(err, PermissionsError::InvalidXml(_)));
1577    }
1578
1579    #[test]
1580    fn profile_with_two_anchors_for_federation() {
1581        let pk1 = ecdsa_p256_test_pubkey_base64();
1582        let pk2 = ecdsa_p256_test_pubkey_base64();
1583        let xml = alloc::format!(
1584            r#"<?xml version="1.0"?>
1585<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1586  <zerodds:delegation_profiles>
1587    <zerodds:profile name="federation">
1588      <zerodds:trust_policy>federation</zerodds:trust_policy>
1589      <zerodds:max_chain_depth>5</zerodds:max_chain_depth>
1590      <zerodds:allowed_algorithms>
1591        <zerodds:algorithm>ecdsa-p256</zerodds:algorithm>
1592      </zerodds:allowed_algorithms>
1593      <zerodds:trust_anchors>
1594        <zerodds:anchor subject_guid="01020304050607080910111213141516"
1595                        algorithm="ecdsa-p256"
1596                        public_key="{pk1}" />
1597        <zerodds:anchor subject_guid="aabbccddeeff00112233445566778899"
1598                        algorithm="ecdsa-p256"
1599                        public_key="{pk2}" />
1600      </zerodds:trust_anchors>
1601    </zerodds:profile>
1602  </zerodds:delegation_profiles>
1603</dds>"#
1604        );
1605        let g = parse_governance_xml(&xml).unwrap();
1606        let p = g.delegation_profiles.get("federation").unwrap();
1607        assert!(matches!(p.trust_policy, TrustPolicy::Federation));
1608        assert_eq!(p.max_chain_depth, 5);
1609        assert_eq!(p.trust_anchors.len(), 2);
1610    }
1611
1612    #[test]
1613    fn edge_without_name_attribute_returns_error() {
1614        const XML: &str = r#"<?xml version="1.0"?>
1615<dds xmlns:zerodds="https://zerodds.org/schema/security/heterogeneous">
1616  <zerodds:edge_identities>
1617    <zerodds:edge guid_prefix="010203040506070809101112" />
1618  </zerodds:edge_identities>
1619</dds>"#;
1620        let err = parse_governance_xml(XML).expect_err("must fail");
1621        assert!(matches!(err, PermissionsError::InvalidXml(_)));
1622    }
1623}