1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum ProtectionKind {
40 #[default]
42 None,
43 Sign,
45 Encrypt,
47 SignWithOriginAuthentication,
49 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, }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct TopicRule {
71 pub topic_expression: String,
73 pub enable_discovery_protection: bool,
75 pub enable_liveliness_protection: bool,
77 pub enable_read_access_control: bool,
79 pub enable_write_access_control: bool,
81 pub metadata_protection_kind: ProtectionKind,
83 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
104pub struct DomainFilter {
105 pub ranges: Vec<(u32, u32)>,
107}
108
109impl DomainFilter {
110 #[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
125pub struct DomainRule {
126 pub domains: DomainFilter,
128 pub allow_unauthenticated_participants: bool,
130 pub enable_join_access_control: bool,
132 pub discovery_protection_kind: ProtectionKind,
134 pub liveliness_protection_kind: ProtectionKind,
136 pub rtps_protection_kind: ProtectionKind,
138 pub topic_rules: Vec<TopicRule>,
140 pub peer_classes: Vec<PeerClass>,
145 pub interface_bindings: Vec<InterfaceBindingRule>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Default)]
159pub struct PeerClass {
160 pub name: String,
163 pub protection: ProtectionKind,
166 pub match_criteria: PeerClassMatch,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Default)]
175pub struct PeerClassMatch {
176 pub auth_plugin_class: Option<String>,
181 pub cert_cn_pattern: Option<String>,
184 pub suite: Option<String>,
187 pub require_ocsp: bool,
190 pub delegation_profile: Option<String>,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Default)]
206pub struct InterfaceBindingRule {
207 pub name: String,
210 pub protection_override: Option<ProtectionKind>,
213 pub peer_class_filter: Vec<String>,
216 pub protection_min: Option<ProtectionKind>,
220}
221
222pub const ZERODDS_NS: &str = "https://zerodds.org/schema/security/heterogeneous";
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
232#[non_exhaustive]
233pub enum EdgeIdentityMode {
234 #[default]
236 Static,
237 Ephemeral,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct EdgeIdentityConfig {
247 pub name: String,
249 pub mode: EdgeIdentityMode,
251 pub guid_prefix: Option<[u8; 12]>,
254 pub lifetime_seconds: Option<u32>,
256}
257
258pub const DEFAULT_EPHEMERAL_LIFETIME_SECS: u32 = 300;
260
261impl EdgeIdentityConfig {
262 #[must_use]
264 pub fn effective_lifetime(&self) -> u32 {
265 self.lifetime_seconds
266 .unwrap_or(DEFAULT_EPHEMERAL_LIFETIME_SECS)
267 }
268
269 #[must_use]
271 pub fn is_ephemeral(&self) -> bool {
272 matches!(self.mode, EdgeIdentityMode::Ephemeral)
273 }
274}
275
276#[derive(Debug, Clone, Default, PartialEq, Eq)]
278pub struct Governance {
279 pub domain_rules: Vec<DomainRule>,
281 pub edge_identities: Vec<EdgeIdentityConfig>,
284 pub delegation_profiles: BTreeMap<String, DelegationProfile>,
288}
289
290impl Governance {
291 #[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 #[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
316pub 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
364fn 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
512fn 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
532fn base64_decode_anchor(input: &str) -> Option<Vec<u8>> {
534 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
576fn 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
633fn 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
656fn 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 "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 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#[must_use]
790pub fn cn_pattern_match(pattern: &str, cn: &str) -> bool {
791 let parts: Vec<&str> = pattern.split('*').collect();
795 if parts.len() == 1 {
796 return pattern == cn;
797 }
798 let mut idx = 0usize;
799 if !parts[0].is_empty() {
801 if !cn.starts_with(parts[0]) {
802 return false;
803 }
804 idx = parts[0].len();
805 }
806 for (i, p) in parts.iter().enumerate().skip(1) {
808 if p.is_empty() {
809 continue;
811 }
812 let is_last = i == parts.len() - 1;
813 if is_last {
814 if !cn[idx..].ends_with(p) {
816 return false;
817 }
818 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 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 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 #[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 #[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 assert_eq!(
1121 rule.peer_classes[0]
1122 .match_criteria
1123 .auth_plugin_class
1124 .as_deref(),
1125 Some("")
1126 );
1127
1128 assert_eq!(
1130 rule.peer_classes[1]
1131 .match_criteria
1132 .cert_cn_pattern
1133 .as_deref(),
1134 Some("*.fast.example")
1135 );
1136
1137 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 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 #[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 #[test]
1200 fn pure_omg_governance_yields_empty_peer_classes_and_bindings() {
1201 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 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 #[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 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 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 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 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}