Skip to main content

zerodds_security_permissions/
xml.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Permissions-XML-Parser (OMG DDS-Security 1.1 §9.4.1.3).
5
6use alloc::string::{String, ToString};
7use alloc::vec::Vec;
8
9/// Parse-Fehler.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum PermissionsError {
12    /// XML-Parsing gescheitert.
13    InvalidXml(String),
14    /// Struktur-Fehler (fehlendes Pflicht-Element).
15    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/// Validity-Periode: `not_before <= now < not_after`. Werte sind
31/// ISO-8601-Strings aus dem XML; der Parser konvertiert sie zu
32/// Unix-Epoch-Seconds (u64). Spec §9.4.1.3.2.2.
33///
34/// future-major: Enforcement erfolgt zur Access-Check-Zeit via
35/// [`Grant::is_valid_at`]. Das Plugin selbst pruefen `now` gegen jeden
36/// `check_create_*`-Call — Uhren-Drift-Toleranz liegt im Caller.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct Validity {
39    /// Inklusive Unter-Grenze (Unix-Epoch-Seconds). `0` = keine.
40    pub not_before: u64,
41    /// Exklusive Ober-Grenze (Unix-Epoch-Seconds). `u64::MAX` = keine.
42    pub not_after: u64,
43}
44
45impl Default for Validity {
46    fn default() -> Self {
47        Self::unrestricted()
48    }
49}
50
51impl Validity {
52    /// Unbeschraenkte Validity — immer gueltig.
53    #[must_use]
54    pub const fn unrestricted() -> Self {
55        Self {
56            not_before: 0,
57            not_after: u64::MAX,
58        }
59    }
60
61    /// `true` wenn `not_before <= now < not_after`.
62    #[must_use]
63    pub const fn contains(&self, now: u64) -> bool {
64        now >= self.not_before && now < self.not_after
65    }
66}
67
68/// Ein Grant-Eintrag: pro Subject welche Topics erlaubt sind.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Grant {
71    /// Subject-Name aus dem Zertifikat (z.B. `"CN=alice"`).
72    pub subject_name: String,
73    /// Topic-Patterns (Glob), die der Subject publizieren darf.
74    pub allow_publish_topics: Vec<String>,
75    /// Topic-Patterns, die der Subject subscriben darf.
76    pub allow_subscribe_topics: Vec<String>,
77    /// Topic-Patterns, die explizit verboten sind (`<deny_rule>`,
78    /// Spec §10.4.1.3 Tab.51). Hat Vorrang vor allow_*.
79    pub deny_publish_topics: Vec<String>,
80    /// Topic-Patterns, die explizit zur Subscribe verboten sind.
81    pub deny_subscribe_topics: Vec<String>,
82    /// Domain-Range — Spec §10.4.1.3 `<domains>`. Leere Liste =
83    /// alle Domains erlaubt.
84    pub domains: Vec<DomainRange>,
85    /// Partition-Patterns (`<partitions><partition>P</partition>
86    /// </partitions>`).
87    pub partitions: Vec<String>,
88    /// Data-Tags (`<data_tags><tag><name>X</name><value>Y</value>
89    /// </tag></data_tags>`). Spec §10.4.1.3 Tab.51 + DataTagging.
90    pub data_tags: Vec<DataTag>,
91    /// `true` = default-Deny (alles nicht-allow-gelistete wird
92    /// verweigert). `false` = default-Allow (Allow-Liste ist eine
93    /// Exception-Liste auf DENY-Basis — selten genutzt).
94    pub default_deny: bool,
95    /// Validity-Periode (Spec §9.4.1.3.2.2). Default `unrestricted`.
96    pub validity: Validity,
97}
98
99/// Domain-Id-Range nach Spec §10.4.1.3 `<domains>`-Element.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct DomainRange {
102    /// Untere Domain-Id (inklusiv).
103    pub min: u32,
104    /// Obere Domain-Id (inklusiv).
105    pub max: u32,
106}
107
108impl DomainRange {
109    /// Konstruktor fuer Single-Id-Range.
110    #[must_use]
111    pub const fn single(id: u32) -> Self {
112        Self { min: id, max: id }
113    }
114
115    /// `true` wenn `id` im Range liegt.
116    #[must_use]
117    pub const fn contains(&self, id: u32) -> bool {
118        id >= self.min && id <= self.max
119    }
120}
121
122/// Data-Tag (Spec §10.4.1.3 + DataTagging-QoS). Pro Subject
123/// auflistbare (name, value)-Tupel, die in `DataTags` der QoS-
124/// Policy gespiegelt werden.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct DataTag {
127    /// Tag-Name.
128    pub name: String,
129    /// Tag-Value.
130    pub value: String,
131}
132
133impl Grant {
134    /// `true` wenn der Grant zum Zeitpunkt `now` (Unix-Seconds) aktiv
135    /// ist. Caller liefert `now` — typisch aus
136    /// `SystemTime::now()`-Wrapper.
137    #[must_use]
138    pub const fn is_valid_at(&self, now: u64) -> bool {
139        self.validity.contains(now)
140    }
141
142    /// Spec §10.4.1.3: Publish-Zugriff erlaubt? Reihenfolge:
143    /// deny_rule (Vorrang) → allow_rule → default.
144    #[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    /// Subscribe-Zugriff erlaubt? Selbe Reihenfolge wie publish.
164    #[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    /// Spec §10.4.1.3 `<domains>`: ist die Domain-Id im erlaubten
184    /// Range? Leere Domain-Liste = alle Domains erlaubt.
185    #[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/// Vollstaendige Permissions-Datei.
195#[derive(Debug, Clone, Default, PartialEq, Eq)]
196pub struct Permissions {
197    /// Alle Grants. Reihenfolge ist relevant (erster Match gewinnt).
198    pub grants: Vec<Grant>,
199}
200
201impl Permissions {
202    /// Findet den Grant fuer ein Subject. Falls kein Grant matcht,
203    /// `None`.
204    #[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
210/// Parsed ein Permissions-XML-Dokument.
211///
212/// Akzeptiert das vereinfachte Schema aus Spec §9.4.1.3; ignoriert
213/// `<validity>` und `<default>` auf Root-Grant-Level (Default wird
214/// aus `<default>DENY</default>` / `ALLOW` abgeleitet — unbekannter
215/// Text → Default-DENY).
216///
217/// # Errors
218/// Siehe [`PermissionsError`].
219pub 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    // Root ist typisch `<dds>` oder direkt `<permissions>`. Wir
225    // suchen rekursiv nach `<grant>`-Knoten — tolerant gegen die
226    // drei bekannten Hierarchie-Varianten (Cyclone / Fast-DDS /
227    // Connext).
228    let mut grants = Vec::new();
229    walk_grants(root, &mut grants)?;
230    Ok(Permissions { grants })
231}
232
233/// zerodds-lint: recursion-depth = xml-tree-depth (≤ 16 in Praxis;
234/// `roxmltree` schuetzt vor >1000-stufiger Rekursion im Parser
235/// selbst, wir delegieren implizit darauf).
236fn 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    // Subject-Name: Kind-Element `<subject_name>` oder Attribut `name`
252    // (je nach Vendor).
253    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    // Spec §10.4.1.3 Tab.51 — `<deny_rule>` hat Vorrang vor allow_rule.
284    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    // Domains-Range auf Grant-Level.
294    let domains = parse_domains(grant);
295    // Data-Tags.
296    let data_tags = parse_data_tags(grant);
297
298    // Default-Behandlung: `<default>DENY</default>` ist Spec-konform
299    // (alles nicht-Allow-gelistete wird verweigert).
300    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        // Wenn kein `<default>` angegeben, nehmen wir wie Fast-DDS
309        // DENY als sicheren Default.
310        .unwrap_or(true);
311
312    // Validity-Periode (Spec §9.4.1.3.2.2). Fehlende Children → 0 /
313    // u64::MAX (unbegrenzt).
314    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            // Single-Id: `<id>5</id>`.
338            "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            // Range: `<id_range><min>5</min><max>10</max></id_range>`.
344            "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
425/// Minimaler ISO-8601-Parser fuer den gaengigsten Spec-Fall:
426/// `YYYY-MM-DDTHH:MM:SS` mit optionalem `Z` / `+00:00`-Suffix. Gibt
427/// Unix-Epoch-Seconds zurueck.
428///
429/// WICHTIG: Dieser Parser implementiert keine kompletten Timezone-
430/// Offsets — alles ausser `Z` oder fehlend wird als UTC interpretiert.
431/// Fuer vollstaendige ISO-8601 kommt in future-major+ ein `time`-Crate-
432/// Adapter (aktuell MSRV-geblockt).
433fn parse_iso_seconds(s: &str) -> Option<u64> {
434    let s = s.trim();
435    // Format: YYYY-MM-DDTHH:MM:SS(Z|+HH:MM|-HH:MM|)
436    // Laenge mind. 19 (ohne Timezone).
437    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    // Days from 1970-01-01 (Howard-Hinnant civil_from_days-Algorithmus,
464    // ohne externe Deps).
465    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; // [0, 399]
468    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    // Akzeptiere sowohl <topics><topic>X</topic></topics> als auch
484    // direkt <topic>X</topic> unter <publish>/<subscribe>.
485    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    // -------------------------------------------------------------
569    // future-major — Validity-Periode
570    // -------------------------------------------------------------
571
572    #[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        // Immer gueltig — auch bei now=0 und u64::MAX.
577        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        // 2026-04-24T00:00:00Z = 20567 days * 86400 = 1 776 988 800
586        assert_eq!(
587            parse_iso_seconds("2026-04-24T00:00:00Z"),
588            Some(1_776_988_800)
589        );
590        // 2000-01-01T00:00:00Z = 946 684 800
591        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); // Monat 13
599        assert_eq!(parse_iso_seconds("2026-04-24T25:00:00"), None); // Stunde 25
600    }
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    // ========================================================================
644    // Erweiterte Permissions: deny_rule + domains + partitions + data_tags.
645    // ========================================================================
646
647    #[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        // Default-Allow + deny_rule lockdown.
800        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}