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");
}
}