zerodds_security_permissions/
xml.rs1use alloc::string::{String, ToString};
7use alloc::vec::Vec;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum PermissionsError {
12 InvalidXml(String),
14 Malformed(String),
16}
17
18impl core::fmt::Display for PermissionsError {
19 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
20 match self {
21 Self::InvalidXml(m) => write!(f, "invalid XML: {m}"),
22 Self::Malformed(m) => write!(f, "malformed permissions: {m}"),
23 }
24 }
25}
26
27#[cfg(feature = "std")]
28impl std::error::Error for PermissionsError {}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct Validity {
39 pub not_before: u64,
41 pub not_after: u64,
43}
44
45impl Default for Validity {
46 fn default() -> Self {
47 Self::unrestricted()
48 }
49}
50
51impl Validity {
52 #[must_use]
54 pub const fn unrestricted() -> Self {
55 Self {
56 not_before: 0,
57 not_after: u64::MAX,
58 }
59 }
60
61 #[must_use]
63 pub const fn contains(&self, now: u64) -> bool {
64 now >= self.not_before && now < self.not_after
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Grant {
71 pub subject_name: String,
73 pub allow_publish_topics: Vec<String>,
75 pub allow_subscribe_topics: Vec<String>,
77 pub deny_publish_topics: Vec<String>,
80 pub deny_subscribe_topics: Vec<String>,
82 pub domains: Vec<DomainRange>,
85 pub partitions: Vec<String>,
88 pub data_tags: Vec<DataTag>,
91 pub default_deny: bool,
95 pub validity: Validity,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct DomainRange {
102 pub min: u32,
104 pub max: u32,
106}
107
108impl DomainRange {
109 #[must_use]
111 pub const fn single(id: u32) -> Self {
112 Self { min: id, max: id }
113 }
114
115 #[must_use]
117 pub const fn contains(&self, id: u32) -> bool {
118 id >= self.min && id <= self.max
119 }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct DataTag {
127 pub name: String,
129 pub value: String,
131}
132
133impl Grant {
134 #[must_use]
138 pub const fn is_valid_at(&self, now: u64) -> bool {
139 self.validity.contains(now)
140 }
141
142 #[must_use]
145 pub fn is_publish_allowed(&self, topic: &str) -> bool {
146 if self
147 .deny_publish_topics
148 .iter()
149 .any(|p| crate::topic_match::topic_match(p, topic))
150 {
151 return false;
152 }
153 if self
154 .allow_publish_topics
155 .iter()
156 .any(|p| crate::topic_match::topic_match(p, topic))
157 {
158 return true;
159 }
160 !self.default_deny
161 }
162
163 #[must_use]
165 pub fn is_subscribe_allowed(&self, topic: &str) -> bool {
166 if self
167 .deny_subscribe_topics
168 .iter()
169 .any(|p| crate::topic_match::topic_match(p, topic))
170 {
171 return false;
172 }
173 if self
174 .allow_subscribe_topics
175 .iter()
176 .any(|p| crate::topic_match::topic_match(p, topic))
177 {
178 return true;
179 }
180 !self.default_deny
181 }
182
183 #[must_use]
186 pub fn matches_domain(&self, domain_id: u32) -> bool {
187 if self.domains.is_empty() {
188 return true;
189 }
190 self.domains.iter().any(|r| r.contains(domain_id))
191 }
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Eq)]
196pub struct Permissions {
197 pub grants: Vec<Grant>,
199}
200
201impl Permissions {
202 #[must_use]
205 pub fn find_grant(&self, subject_name: &str) -> Option<&Grant> {
206 self.grants.iter().find(|g| g.subject_name == subject_name)
207 }
208}
209
210pub fn parse_permissions_xml(xml: &str) -> Result<Permissions, PermissionsError> {
220 let doc =
221 roxmltree::Document::parse(xml).map_err(|e| PermissionsError::InvalidXml(e.to_string()))?;
222 let root = doc.root_element();
223
224 let mut grants = Vec::new();
229 walk_grants(root, &mut grants)?;
230 Ok(Permissions { grants })
231}
232
233fn walk_grants(
237 node: roxmltree::Node<'_, '_>,
238 out: &mut Vec<Grant>,
239) -> Result<(), PermissionsError> {
240 if node.tag_name().name() == "grant" {
241 out.push(parse_grant(node)?);
242 return Ok(());
243 }
244 for child in node.children().filter(roxmltree::Node::is_element) {
245 walk_grants(child, out)?;
246 }
247 Ok(())
248}
249
250fn parse_grant(grant: roxmltree::Node<'_, '_>) -> Result<Grant, PermissionsError> {
251 let subject_name = grant
254 .children()
255 .find(|c| c.has_tag_name("subject_name"))
256 .and_then(|c| c.text().map(str::trim).map(str::to_owned))
257 .or_else(|| grant.attribute("name").map(str::to_owned))
258 .ok_or_else(|| {
259 PermissionsError::Malformed("<grant> ohne <subject_name> oder name=".into())
260 })?;
261
262 let mut allow_publish_topics = Vec::new();
263 let mut allow_subscribe_topics = Vec::new();
264 let mut deny_publish_topics = Vec::new();
265 let mut deny_subscribe_topics = Vec::new();
266 let mut partitions = Vec::new();
267
268 for rule in grant.children().filter(|c| c.has_tag_name("allow_rule")) {
269 for op in rule.children().filter(roxmltree::Node::is_element) {
270 match op.tag_name().name() {
271 "publish" => {
272 collect_topics(op, &mut allow_publish_topics);
273 collect_partitions(op, &mut partitions);
274 }
275 "subscribe" => {
276 collect_topics(op, &mut allow_subscribe_topics);
277 collect_partitions(op, &mut partitions);
278 }
279 _ => {}
280 }
281 }
282 }
283 for rule in grant.children().filter(|c| c.has_tag_name("deny_rule")) {
285 for op in rule.children().filter(roxmltree::Node::is_element) {
286 match op.tag_name().name() {
287 "publish" => collect_topics(op, &mut deny_publish_topics),
288 "subscribe" => collect_topics(op, &mut deny_subscribe_topics),
289 _ => {}
290 }
291 }
292 }
293 let domains = parse_domains(grant);
295 let data_tags = parse_data_tags(grant);
297
298 let default_deny = grant
301 .children()
302 .find(|c| c.has_tag_name("default"))
303 .and_then(|c| c.text())
304 .map(|t| {
305 let t = t.trim().to_uppercase();
306 t == "DENY" || t == "DISALLOW"
307 })
308 .unwrap_or(true);
311
312 let validity = parse_validity(grant);
315
316 Ok(Grant {
317 subject_name,
318 allow_publish_topics,
319 allow_subscribe_topics,
320 deny_publish_topics,
321 deny_subscribe_topics,
322 domains,
323 partitions,
324 data_tags,
325 default_deny,
326 validity,
327 })
328}
329
330fn parse_domains(grant: roxmltree::Node<'_, '_>) -> Vec<DomainRange> {
331 let Some(dnode) = grant.children().find(|c| c.has_tag_name("domains")) else {
332 return Vec::new();
333 };
334 let mut out = Vec::new();
335 for child in dnode.children().filter(roxmltree::Node::is_element) {
336 match child.tag_name().name() {
337 "id" => {
339 if let Some(id) = child.text().and_then(|t| t.trim().parse::<u32>().ok()) {
340 out.push(DomainRange::single(id));
341 }
342 }
343 "id_range" => {
345 let min = child
346 .children()
347 .find(|c| c.has_tag_name("min"))
348 .and_then(|c| c.text())
349 .and_then(|t| t.trim().parse::<u32>().ok())
350 .unwrap_or(0);
351 let max = child
352 .children()
353 .find(|c| c.has_tag_name("max"))
354 .and_then(|c| c.text())
355 .and_then(|t| t.trim().parse::<u32>().ok())
356 .unwrap_or(u32::MAX);
357 out.push(DomainRange { min, max });
358 }
359 _ => {}
360 }
361 }
362 out
363}
364
365fn parse_data_tags(grant: roxmltree::Node<'_, '_>) -> Vec<DataTag> {
366 let Some(node) = grant.children().find(|c| c.has_tag_name("data_tags")) else {
367 return Vec::new();
368 };
369 let mut out = Vec::new();
370 for tag in node.children().filter(|c| c.has_tag_name("tag")) {
371 let name = tag
372 .children()
373 .find(|c| c.has_tag_name("name"))
374 .and_then(|c| c.text())
375 .map(|t| t.trim().to_string())
376 .unwrap_or_default();
377 let value = tag
378 .children()
379 .find(|c| c.has_tag_name("value"))
380 .and_then(|c| c.text())
381 .map(|t| t.trim().to_string())
382 .unwrap_or_default();
383 if !name.is_empty() {
384 out.push(DataTag { name, value });
385 }
386 }
387 out
388}
389
390fn collect_partitions(op: roxmltree::Node<'_, '_>, out: &mut Vec<String>) {
391 if let Some(part_node) = op.children().find(|c| c.has_tag_name("partitions")) {
392 for p in part_node.children().filter(|c| c.has_tag_name("partition")) {
393 if let Some(t) = p.text() {
394 let trimmed = t.trim().to_string();
395 if !out.contains(&trimmed) {
396 out.push(trimmed);
397 }
398 }
399 }
400 }
401}
402
403fn parse_validity(grant: roxmltree::Node<'_, '_>) -> Validity {
404 let Some(vnode) = grant.children().find(|c| c.has_tag_name("validity")) else {
405 return Validity::unrestricted();
406 };
407 let not_before = vnode
408 .children()
409 .find(|c| c.has_tag_name("not_before"))
410 .and_then(|c| c.text())
411 .and_then(parse_iso_seconds)
412 .unwrap_or(0);
413 let not_after = vnode
414 .children()
415 .find(|c| c.has_tag_name("not_after"))
416 .and_then(|c| c.text())
417 .and_then(parse_iso_seconds)
418 .unwrap_or(u64::MAX);
419 Validity {
420 not_before,
421 not_after,
422 }
423}
424
425fn parse_iso_seconds(s: &str) -> Option<u64> {
434 let s = s.trim();
435 if s.len() < 19 {
438 return None;
439 }
440 let bytes = s.as_bytes();
441 if bytes[4] != b'-'
442 || bytes[7] != b'-'
443 || bytes[10] != b'T'
444 || bytes[13] != b':'
445 || bytes[16] != b':'
446 {
447 return None;
448 }
449 let year: i32 = s[0..4].parse().ok()?;
450 let month: u32 = s[5..7].parse().ok()?;
451 let day: u32 = s[8..10].parse().ok()?;
452 let hour: u32 = s[11..13].parse().ok()?;
453 let minute: u32 = s[14..16].parse().ok()?;
454 let second: u32 = s[17..19].parse().ok()?;
455
456 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
457 return None;
458 }
459 if hour > 23 || minute > 59 || second > 60 {
460 return None;
461 }
462
463 let y = year - i32::from(month <= 2);
466 let era = if y >= 0 { y } else { y - 399 } / 400;
467 let yoe = (y - era * 400) as u32; let m = month as i32;
469 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i32 - 1;
470 let doe = yoe as i32 * 365 + yoe as i32 / 4 - yoe as i32 / 100 + doy;
471 let days_from_epoch = era as i64 * 146_097 + doe as i64 - 719_468;
472 if days_from_epoch < 0 {
473 return None;
474 }
475 let secs = days_from_epoch as u64 * 86_400
476 + u64::from(hour) * 3_600
477 + u64::from(minute) * 60
478 + u64::from(second);
479 Some(secs)
480}
481
482fn collect_topics(op: roxmltree::Node<'_, '_>, out: &mut Vec<String>) {
483 for topic in op.descendants().filter(|c| c.has_tag_name("topic")) {
486 if let Some(txt) = topic.text() {
487 out.push(txt.trim().to_string());
488 }
489 }
490}
491
492#[cfg(test)]
493#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
494mod tests {
495 use super::*;
496
497 const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
498<dds>
499 <permissions>
500 <grant name="alice">
501 <subject_name>CN=alice</subject_name>
502 <allow_rule>
503 <publish>
504 <topics>
505 <topic>Chatter</topic>
506 <topic>sensor_*</topic>
507 </topics>
508 </publish>
509 <subscribe>
510 <topic>Echo</topic>
511 </subscribe>
512 </allow_rule>
513 <default>DENY</default>
514 </grant>
515 <grant>
516 <subject_name>CN=bob</subject_name>
517 <allow_rule>
518 <publish><topic>Temperature</topic></publish>
519 </allow_rule>
520 <default>DENY</default>
521 </grant>
522 </permissions>
523</dds>
524"#;
525
526 #[test]
527 fn parses_grants_and_topics() {
528 let p = parse_permissions_xml(SAMPLE).expect("parse");
529 assert_eq!(p.grants.len(), 2);
530
531 let alice = p.find_grant("CN=alice").expect("alice");
532 assert_eq!(
533 alice.allow_publish_topics,
534 vec!["Chatter".to_string(), "sensor_*".to_string()],
535 );
536 assert_eq!(alice.allow_subscribe_topics, vec!["Echo".to_string()]);
537 assert!(alice.default_deny);
538
539 let bob = p.find_grant("CN=bob").expect("bob");
540 assert_eq!(bob.allow_publish_topics, vec!["Temperature".to_string()]);
541 assert!(bob.allow_subscribe_topics.is_empty());
542 }
543
544 #[test]
545 fn rejects_invalid_xml() {
546 let err = parse_permissions_xml("<not-closed").unwrap_err();
547 assert!(matches!(err, PermissionsError::InvalidXml(_)));
548 }
549
550 #[test]
551 fn missing_subject_name_is_malformed() {
552 let xml = r#"<permissions><grant><allow_rule/></grant></permissions>"#;
553 let err = parse_permissions_xml(xml).unwrap_err();
554 assert!(matches!(err, PermissionsError::Malformed(_)));
555 }
556
557 #[test]
558 fn default_deny_without_explicit_tag() {
559 let xml = r#"
560 <permissions>
561 <grant name="x"><subject_name>CN=x</subject_name></grant>
562 </permissions>
563 "#;
564 let p = parse_permissions_xml(xml).unwrap();
565 assert!(p.grants[0].default_deny);
566 }
567
568 #[test]
573 fn missing_validity_defaults_to_unrestricted() {
574 let p = parse_permissions_xml(SAMPLE).unwrap();
575 assert_eq!(p.grants[0].validity, Validity::unrestricted());
576 assert!(p.grants[0].is_valid_at(0));
578 assert!(p.grants[0].is_valid_at(u64::MAX - 1));
579 }
580
581 #[test]
582 fn iso_parser_unix_epoch() {
583 assert_eq!(parse_iso_seconds("1970-01-01T00:00:00Z"), Some(0));
584 assert_eq!(parse_iso_seconds("1970-01-01T00:00:01Z"), Some(1));
585 assert_eq!(
587 parse_iso_seconds("2026-04-24T00:00:00Z"),
588 Some(1_776_988_800)
589 );
590 assert_eq!(parse_iso_seconds("2000-01-01T00:00:00Z"), Some(946_684_800));
592 }
593
594 #[test]
595 fn iso_parser_rejects_malformed() {
596 assert_eq!(parse_iso_seconds("not-a-date"), None);
597 assert_eq!(parse_iso_seconds("2026/04/24"), None);
598 assert_eq!(parse_iso_seconds("2026-13-01T00:00:00"), None); assert_eq!(parse_iso_seconds("2026-04-24T25:00:00"), None); }
601
602 #[test]
603 fn validity_window_enforced() {
604 let xml = r#"
605<permissions>
606 <grant>
607 <subject_name>CN=alice</subject_name>
608 <validity>
609 <not_before>2026-01-01T00:00:00Z</not_before>
610 <not_after>2027-01-01T00:00:00Z</not_after>
611 </validity>
612 <allow_rule><publish><topic>T</topic></publish></allow_rule>
613 </grant>
614</permissions>
615"#;
616 let p = parse_permissions_xml(xml).unwrap();
617 let g = &p.grants[0];
618 let not_before = parse_iso_seconds("2026-01-01T00:00:00Z").unwrap();
619 let not_after = parse_iso_seconds("2027-01-01T00:00:00Z").unwrap();
620 assert!(!g.is_valid_at(not_before - 1), "gerade vor not_before");
621 assert!(g.is_valid_at(not_before), "genau not_before (inklusiv)");
622 assert!(g.is_valid_at(not_before + 3600), "mitten drin");
623 assert!(!g.is_valid_at(not_after), "genau not_after (exklusiv)");
624 assert!(!g.is_valid_at(u64::MAX / 2), "weit nach not_after");
625 }
626
627 #[test]
628 fn validity_only_not_after_set() {
629 let xml = r#"
630<permissions>
631 <grant>
632 <subject_name>CN=bob</subject_name>
633 <validity><not_after>2030-01-01T00:00:00Z</not_after></validity>
634 </grant>
635</permissions>
636"#;
637 let p = parse_permissions_xml(xml).unwrap();
638 assert_eq!(p.grants[0].validity.not_before, 0);
639 assert!(p.grants[0].validity.not_after < u64::MAX);
640 assert!(p.grants[0].is_valid_at(0));
641 }
642
643 #[test]
648 fn deny_rule_overrides_allow_for_publish() {
649 let xml = r#"
650<permissions>
651 <grant>
652 <subject_name>CN=alice</subject_name>
653 <allow_rule><publish><topics><topic>*</topic></topics></publish></allow_rule>
654 <deny_rule><publish><topics><topic>secret/*</topic></topics></publish></deny_rule>
655 </grant>
656</permissions>
657"#;
658 let p = parse_permissions_xml(xml).unwrap();
659 let g = &p.grants[0];
660 assert!(g.is_publish_allowed("public/news"));
661 assert!(!g.is_publish_allowed("secret/keys"), "deny_rule must win");
662 }
663
664 #[test]
665 fn deny_rule_overrides_allow_for_subscribe() {
666 let xml = r#"
667<permissions>
668 <grant>
669 <subject_name>CN=alice</subject_name>
670 <allow_rule><subscribe><topics><topic>*</topic></topics></subscribe></allow_rule>
671 <deny_rule><subscribe><topics><topic>internal/*</topic></topics></subscribe></deny_rule>
672 </grant>
673</permissions>
674"#;
675 let p = parse_permissions_xml(xml).unwrap();
676 let g = &p.grants[0];
677 assert!(g.is_subscribe_allowed("public/x"));
678 assert!(!g.is_subscribe_allowed("internal/db"));
679 }
680
681 #[test]
682 fn domains_single_id_parsed() {
683 let xml = r#"
684<permissions>
685 <grant>
686 <subject_name>CN=alice</subject_name>
687 <domains><id>5</id><id>7</id></domains>
688 </grant>
689</permissions>
690"#;
691 let p = parse_permissions_xml(xml).unwrap();
692 let g = &p.grants[0];
693 assert_eq!(g.domains.len(), 2);
694 assert!(g.matches_domain(5));
695 assert!(g.matches_domain(7));
696 assert!(!g.matches_domain(6));
697 }
698
699 #[test]
700 fn domains_id_range_parsed() {
701 let xml = r#"
702<permissions>
703 <grant>
704 <subject_name>CN=alice</subject_name>
705 <domains><id_range><min>10</min><max>20</max></id_range></domains>
706 </grant>
707</permissions>
708"#;
709 let p = parse_permissions_xml(xml).unwrap();
710 let g = &p.grants[0];
711 assert!(g.matches_domain(10));
712 assert!(g.matches_domain(15));
713 assert!(g.matches_domain(20));
714 assert!(!g.matches_domain(9));
715 assert!(!g.matches_domain(21));
716 }
717
718 #[test]
719 fn empty_domains_means_all_allowed() {
720 let xml = r#"
721<permissions>
722 <grant>
723 <subject_name>CN=alice</subject_name>
724 </grant>
725</permissions>
726"#;
727 let p = parse_permissions_xml(xml).unwrap();
728 let g = &p.grants[0];
729 assert!(g.domains.is_empty());
730 assert!(g.matches_domain(0));
731 assert!(g.matches_domain(u32::MAX));
732 }
733
734 #[test]
735 fn partitions_collected_from_publish_rule() {
736 let xml = r#"
737<permissions>
738 <grant>
739 <subject_name>CN=alice</subject_name>
740 <allow_rule>
741 <publish>
742 <topics><topic>T</topic></topics>
743 <partitions>
744 <partition>internal</partition>
745 <partition>backup</partition>
746 </partitions>
747 </publish>
748 </allow_rule>
749 </grant>
750</permissions>
751"#;
752 let p = parse_permissions_xml(xml).unwrap();
753 let g = &p.grants[0];
754 assert_eq!(g.partitions.len(), 2);
755 assert!(g.partitions.contains(&"internal".to_string()));
756 assert!(g.partitions.contains(&"backup".to_string()));
757 }
758
759 #[test]
760 fn data_tags_parsed() {
761 let xml = r#"
762<permissions>
763 <grant>
764 <subject_name>CN=alice</subject_name>
765 <data_tags>
766 <tag><name>aws.region</name><value>eu-central-1</value></tag>
767 <tag><name>clearance</name><value>secret</value></tag>
768 </data_tags>
769 </grant>
770</permissions>
771"#;
772 let p = parse_permissions_xml(xml).unwrap();
773 let g = &p.grants[0];
774 assert_eq!(g.data_tags.len(), 2);
775 assert_eq!(g.data_tags[0].name, "aws.region");
776 assert_eq!(g.data_tags[0].value, "eu-central-1");
777 assert_eq!(g.data_tags[1].name, "clearance");
778 }
779
780 #[test]
781 fn data_tag_with_empty_name_skipped() {
782 let xml = r#"
783<permissions>
784 <grant>
785 <subject_name>CN=alice</subject_name>
786 <data_tags>
787 <tag><name></name><value>x</value></tag>
788 <tag><name>k</name><value>v</value></tag>
789 </data_tags>
790 </grant>
791</permissions>
792"#;
793 let p = parse_permissions_xml(xml).unwrap();
794 assert_eq!(p.grants[0].data_tags.len(), 1);
795 }
796
797 #[test]
798 fn deny_only_grant_blocks_specific_topics() {
799 let xml = r#"
801<permissions>
802 <grant>
803 <subject_name>CN=alice</subject_name>
804 <default>ALLOW</default>
805 <deny_rule><publish><topics><topic>X</topic></topics></publish></deny_rule>
806 </grant>
807</permissions>
808"#;
809 let p = parse_permissions_xml(xml).unwrap();
810 let g = &p.grants[0];
811 assert!(g.is_publish_allowed("Y"), "ALLOW default permits Y");
812 assert!(!g.is_publish_allowed("X"), "deny_rule wins over default");
813 }
814
815 #[test]
816 fn domain_range_single_inclusive() {
817 let r = DomainRange::single(42);
818 assert!(r.contains(42));
819 assert!(!r.contains(41));
820 assert!(!r.contains(43));
821 }
822}