ldap-acis 0.2.1

LDAP Access Control Instructions (ACI) system built on acls-rs
Documentation
//! DN (Distinguished Name) parsing and hierarchy utilities.

/// Check if DN matches a pattern that may contain wildcards or $SUFFIX.
pub fn matches_dn_pattern(dn: &str, pattern: &str) -> bool {
    let pattern = pattern.replace("$SUFFIX", "*");

    if pattern.contains('*') {
        let parts: Vec<&str> = pattern.split('*').collect();

        let dn_lower = dn.to_lowercase();
        let mut current_pos = 0;

        for (i, part) in parts.iter().enumerate() {
            if part.is_empty() {
                continue;
            }

            let part_lower = part.to_lowercase();
            let part_lower = part_lower.trim_matches(',');

            if i == 0 {
                if !part_lower.is_empty() {
                    if !dn_lower.starts_with(part_lower) {
                        return false;
                    }
                    current_pos = part_lower.len();
                }
            } else if i == parts.len() - 1 {
                if !part_lower.is_empty() && !dn_lower.ends_with(part_lower) {
                    return false;
                }
            } else if let Some(pos) = dn_lower[current_pos..].find(part_lower) {
                current_pos += pos + part_lower.len();
            } else {
                return false;
            }
        }

        true
    } else {
        dn.eq_ignore_ascii_case(&pattern)
    }
}

/// Check if `dn` is a direct child of `base_dn` (exactly one RDN deeper).
pub fn is_direct_child(dn: &str, base_dn: &str) -> bool {
    if base_dn.contains('*') {
        return matches_dn_pattern(dn, base_dn);
    }

    if dn.eq_ignore_ascii_case(base_dn) {
        return false;
    }

    let dn_lower = dn.to_lowercase();
    let base_lower = base_dn.to_lowercase();

    if !dn_lower.ends_with(&base_lower) {
        return false;
    }

    let prefix = &dn_lower[..dn_lower.len() - base_lower.len()];
    if !prefix.ends_with(',') {
        return false;
    }

    let prefix_trimmed = prefix.trim_end_matches(',');
    !prefix_trimmed.contains(',')
}

/// Check if `dn` is `base_dn` or any descendant.
pub fn is_descendant_or_self(dn: &str, base_dn: &str) -> bool {
    let dn_lower = dn.to_lowercase();
    let base_lower = base_dn.to_lowercase();

    if dn_lower == base_lower {
        return true;
    }

    if dn_lower.ends_with(&base_lower) {
        let prefix = &dn_lower[..dn_lower.len() - base_lower.len()];
        return prefix.ends_with(',');
    }

    false
}

/// Check if a DN string contains wildcard or $SUFFIX patterns.
pub fn has_wildcard(dn: &str) -> bool {
    dn.contains('*') || dn.contains("$SUFFIX")
}

/// A DN split into lowercased RDN components in root-first order.
///
/// For `uid=alice,ou=people,dc=example,dc=com`, components are:
/// `["dc=com", "dc=example", "ou=people", "uid=alice"]`
#[derive(Debug, Clone)]
pub struct ParsedDn {
    pub components: smallvec::SmallVec<[String; 8]>,
}

impl ParsedDn {
    /// Parse a DN string into reversed, lowercased components.
    pub fn parse(dn: &str) -> Self {
        let mut components: smallvec::SmallVec<[String; 8]> =
            dn.split(',').map(|c| c.trim().to_lowercase()).collect();
        components.reverse();
        Self { components }
    }

    /// Number of RDN components.
    pub fn depth(&self) -> usize {
        self.components.len()
    }

    /// Whether `self` is an ancestor of `other` (proper prefix in root-first order).
    pub fn is_ancestor_of(&self, other: &ParsedDn) -> bool {
        if self.components.len() >= other.components.len() {
            return false;
        }
        self.components
            .iter()
            .zip(other.components.iter())
            .all(|(a, b)| a == b)
    }

    /// Whether `self` is the direct parent of `other` (exactly one level up).
    pub fn is_parent_of(&self, other: &ParsedDn) -> bool {
        self.components.len() + 1 == other.components.len() && self.is_ancestor_of(other)
    }

    /// Whether `self` equals `other` or is an ancestor.
    pub fn is_ancestor_or_self(&self, other: &ParsedDn) -> bool {
        if self.components.len() > other.components.len() {
            return false;
        }
        self.components
            .iter()
            .zip(other.components.iter())
            .all(|(a, b)| a == b)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parsed_dn_basic() {
        let dn = ParsedDn::parse("uid=alice,ou=people,dc=example,dc=com");
        assert_eq!(dn.depth(), 4);
        assert_eq!(dn.components[0], "dc=com");
        assert_eq!(dn.components[1], "dc=example");
        assert_eq!(dn.components[2], "ou=people");
        assert_eq!(dn.components[3], "uid=alice");
    }

    #[test]
    fn parsed_dn_case_insensitive() {
        let dn = ParsedDn::parse("UID=Alice,OU=People,DC=Example,DC=Com");
        assert_eq!(dn.components[0], "dc=com");
        assert_eq!(dn.components[3], "uid=alice");
    }

    #[test]
    fn ancestor_checks() {
        let root = ParsedDn::parse("dc=example,dc=com");
        let ou = ParsedDn::parse("ou=people,dc=example,dc=com");
        let user = ParsedDn::parse("uid=alice,ou=people,dc=example,dc=com");

        assert!(root.is_ancestor_of(&ou));
        assert!(root.is_ancestor_of(&user));
        assert!(ou.is_ancestor_of(&user));
        assert!(!user.is_ancestor_of(&ou));
        assert!(!ou.is_ancestor_of(&root));
        assert!(!root.is_ancestor_of(&root));
    }

    #[test]
    fn parent_checks() {
        let root = ParsedDn::parse("dc=example,dc=com");
        let ou = ParsedDn::parse("ou=people,dc=example,dc=com");
        let user = ParsedDn::parse("uid=alice,ou=people,dc=example,dc=com");

        assert!(root.is_parent_of(&ou));
        assert!(ou.is_parent_of(&user));
        assert!(!root.is_parent_of(&user));
    }

    #[test]
    fn ancestor_or_self() {
        let dn = ParsedDn::parse("ou=people,dc=example,dc=com");
        assert!(dn.is_ancestor_or_self(&dn.clone()));
    }

    #[test]
    fn is_direct_child_basic() {
        assert!(is_direct_child(
            "uid=alice,ou=people,dc=example,dc=com",
            "ou=people,dc=example,dc=com"
        ));
        assert!(!is_direct_child(
            "ou=people,dc=example,dc=com",
            "ou=people,dc=example,dc=com"
        ));
        assert!(!is_direct_child(
            "uid=alice,ou=sub,ou=people,dc=example,dc=com",
            "ou=people,dc=example,dc=com"
        ));
    }

    #[test]
    fn is_descendant_or_self_basic() {
        assert!(is_descendant_or_self(
            "uid=alice,ou=people,dc=example,dc=com",
            "ou=people,dc=example,dc=com"
        ));
        assert!(is_descendant_or_self(
            "ou=people,dc=example,dc=com",
            "ou=people,dc=example,dc=com"
        ));
        assert!(is_descendant_or_self(
            "uid=alice,ou=sub,ou=people,dc=example,dc=com",
            "ou=people,dc=example,dc=com"
        ));
        assert!(!is_descendant_or_self(
            "uid=alice,ou=groups,dc=example,dc=com",
            "ou=people,dc=example,dc=com"
        ));
    }

    #[test]
    fn matches_dn_pattern_basic() {
        assert!(matches_dn_pattern(
            "uid=alice,ou=people,dc=example,dc=com",
            "uid=*,ou=people,dc=example,dc=com"
        ));
        assert!(matches_dn_pattern(
            "uid=alice,ou=people,dc=example,dc=com",
            "uid=alice,ou=people,dc=example,dc=com"
        ));
        assert!(!matches_dn_pattern(
            "uid=alice,ou=people,dc=example,dc=com",
            "uid=bob,ou=people,dc=example,dc=com"
        ));
    }

    #[test]
    fn has_wildcard_check() {
        assert!(has_wildcard("uid=*,dc=example,dc=com"));
        assert!(has_wildcard("uid=$SUFFIX,dc=test"));
        assert!(!has_wildcard("uid=alice,dc=example,dc=com"));
    }
}