Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-identity-directory/src/names.rs

//! **How a directory name is compared** (A34 M-6): a user name, an e-mail
//! domain, and the principal ids a NEW employee may take. One copy for every
//! product: the SCIM door, the CSV import and the SSO login all fold a name
//! the same way, so `admin` and a look-alike are never two people.

use unicode_normalization::UnicodeNormalization;

/// The longest user name, in characters.
pub const MAX_USER_NAME_CHARS: usize = 256;

/// A directory user name as it is compared: NFKC-folded (a full-width
/// `admin` is `admin`), trimmed, lowercase, at most 256 characters, no
/// control characters, and **not a mix of Latin, Greek and Cyrillic letters**
/// (`аdmin` with a Cyrillic `а` is refused, not a second `admin`: A34-43).
/// `None` when nothing acceptable is left.
pub fn normalize_user_name(raw: &str) -> Option<String> {
    let folded: String = raw.nfkc().collect::<String>().to_lowercase();
    let name: String = folded.trim().nfkc().collect();
    if name.is_empty()
        || name.chars().count() > MAX_USER_NAME_CHARS
        || name.chars().any(char::is_control)
        || mixed_script(&name)
    {
        return None;
    }
    Some(name)
}

/// The confusable scripts: a name holding letters of two of them is the
/// look-alike trick, never a real person's name.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Script {
    Latin,
    Greek,
    Cyrillic,
}

fn script_of(c: char) -> Option<Script> {
    match c as u32 {
        0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F | 0x1E00..=0x1EFF | 0xA720..=0xA7FF => {
            Some(Script::Latin)
        }
        0x370..=0x3FF | 0x1F00..=0x1FFF => Some(Script::Greek),
        0x400..=0x52F | 0x1C80..=0x1C8F | 0x2DE0..=0x2DFF | 0xA640..=0xA69F => {
            Some(Script::Cyrillic)
        }
        _ => None,
    }
}

/// Whether `name` holds letters of more than one confusable script.
pub fn mixed_script(name: &str) -> bool {
    let mut seen: Option<Script> = None;
    for c in name.chars().filter(|c| c.is_alphabetic()) {
        if let Some(s) = script_of(c) {
            match seen {
                None => seen = Some(s),
                Some(t) if t != s => return true,
                Some(_) => {}
            }
        }
    }
    false
}

/// An email domain as the login page compares it: lowercase, no `@`, a dot
/// inside, the characters of a host name.
pub fn normalize_domain(raw: &str) -> Option<String> {
    let domain = raw
        .trim()
        .trim_start_matches('@')
        .trim_end_matches('.')
        .to_ascii_lowercase();
    let ok = domain.len() <= 253
        && domain.contains('.')
        && !domain.starts_with('.')
        && !domain.contains("..")
        && domain
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-');
    ok.then_some(domain)
}

/// How many numbered candidates (`acme.bob`, `acme.bob-2` … `acme.bob-9`) come
/// before the hashed ones.
pub const NUMBERED_CANDIDATES: usize = 9;
/// How many hashed candidates (`acme.bob-k3f9q2`) follow the numbered ones.
pub const HASHED_CANDIDATES: usize = 4;

/// **The principal ids a NEW employee for `user_name` may take**, in order:
/// `<account>.<local part>`, then `-2` … `-9`, then (A34-42: the 10th person
/// with the same local part was refused) short suffixes derived from the
/// whole user name, so `bob@a.example` and `bob@b.example` never ask for the
/// same one. Every id is at most `max_len` bytes and passes `valid` (the
/// product's id rule). The store takes the first that is free.
pub fn principal_id_candidates(
    account: &str,
    user_name: &str,
    max_len: usize,
    valid: impl Fn(&str) -> bool,
) -> Vec<String> {
    let local = user_name.split('@').next().unwrap_or(user_name);
    let mut cleaned = String::new();
    for c in local.chars() {
        let c = c.to_ascii_lowercase();
        let keep =
            if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' || c == '.' {
                c
            } else {
                '-'
            };
        if (keep == '-' || keep == '.') && cleaned.ends_with(['-', '.']) {
            continue;
        }
        cleaned.push(keep);
    }
    let cleaned = cleaned.trim_matches(['-', '.']).to_owned();
    let cleaned = if cleaned.is_empty() {
        "user".to_owned()
    } else {
        cleaned
    };
    let account = account.to_ascii_lowercase();
    let suffixes = (1..=NUMBERED_CANDIDATES)
        .map(|n| if n == 1 { String::new() } else { format!("-{n}") })
        .chain((0..HASHED_CANDIDATES).map(|salt| format!("-{}", short_hash(user_name, salt))));
    let mut out = Vec::new();
    for suffix in suffixes {
        let room = max_len.saturating_sub(account.len() + 1 + suffix.len());
        if room == 0 {
            break;
        }
        let body: String = cleaned.chars().take(room).collect();
        let body = body.trim_end_matches(['-', '.']);
        let id = format!("{account}.{body}{suffix}");
        if id.len() <= max_len && valid(&id) && !out.contains(&id) {
            out.push(id);
        }
    }
    out
}

/// Six base-36 characters of an FNV-1a hash of the user name and a salt.
fn short_hash(user_name: &str, salt: usize) -> String {
    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
    for b in user_name.bytes().chain((salt as u64).to_le_bytes()) {
        h ^= u64::from(b);
        h = h.wrapping_mul(0x0000_0100_0000_01b3);
    }
    const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz";
    (0..6)
        .map(|_| {
            let d = DIGITS[(h % 36) as usize] as char;
            h /= 36;
            d
        })
        .collect()
}

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

    #[test]
    fn domains_and_names() {
        assert_eq!(
            normalize_domain("@ACME.example").as_deref(),
            Some("acme.example")
        );
        assert_eq!(normalize_domain("localhost"), None);
        assert_eq!(normalize_domain("a..b"), None);
        assert_eq!(
            normalize_user_name("  Bob@Acme.Example "),
            Some("bob@acme.example".to_owned())
        );
        assert_eq!(normalize_user_name(" \t "), None);
        assert_eq!(normalize_user_name("åsa.öberg").as_deref(), Some("åsa.öberg"));
        assert_eq!(normalize_user_name("Ελένη").as_deref(), Some("ελένη"));
    }

    /// ★ **A34-43: NFKC and no mixed scripts.** A full-width `admin` is
    /// `admin`; `аdmin` with a Cyrillic `а` is refused, never a second user.
    #[test]
    fn look_alike_names_fold_or_are_refused() {
        assert_eq!(normalize_user_name("ADMIN").as_deref(), Some("admin"));
        assert_eq!(normalize_user_name("\u{430}dmin"), None);
        assert_eq!(normalize_user_name("p\u{430}ypal@acme.example"), None);
        assert_eq!(normalize_user_name("иван").as_deref(), Some("иван"));
        assert_eq!(normalize_user_name("finn").as_deref(), Some("finn"));
    }

    fn any(_: &str) -> bool {
        true
    }

    #[test]
    fn candidates_are_the_accounts_and_valid_ids() {
        let ids = principal_id_candidates("acme", "Bob.Smith@ACME.example", 64, any);
        assert_eq!(ids[0], "acme.bob.smith");
        assert_eq!(ids[1], "acme.bob.smith-2");
        assert_eq!(ids[8], "acme.bob.smith-9");
        let odd = principal_id_candidates("acme", "  ..--åsa ö'connor--..", 64, any);
        assert_eq!(odd[0], "acme.sa-connor");
        let empty = principal_id_candidates("acme", "@@@", 64, any);
        assert_eq!(empty[0], "acme.user");
        let long = principal_id_candidates("acme", &"x".repeat(200), 64, any);
        assert!(long.iter().all(|id| id.len() <= 64));
        let refused = principal_id_candidates("acme", "bob", 64, |id| !id.ends_with("-2"));
        assert!(!refused.contains(&"acme.bob-2".to_owned()));
    }

    /// ★ **A34-42: the 10th person with the same local part gets an id.** The
    /// numbered candidates stop at `-9`; after them come suffixes derived from
    /// the whole user name, distinct for `bob@a.example` and `bob@b.example`.
    #[test]
    fn the_tenth_same_local_part_still_has_a_candidate() {
        let a = principal_id_candidates("acme", "bob@a.example", 64, any);
        let b = principal_id_candidates("acme", "bob@b.example", 64, any);
        assert_eq!(a.len(), NUMBERED_CANDIDATES + HASHED_CANDIDATES, "{a:?}");
        assert_eq!(a[..9], b[..9]);
        let (ha, hb) = (&a[9..], &b[9..]);
        assert!(ha.iter().all(|id| !hb.contains(id)), "{ha:?} {hb:?}");
        assert!(ha.iter().all(|id| id.starts_with("acme.bob-") && id.len() == "acme.bob-".len() + 6));
        assert_eq!(a, principal_id_candidates("acme", "bob@a.example", 64, any), "deterministic");
    }
}