use unicode_normalization::UnicodeNormalization;
const NON_ASCII_SPACES: &[char] = &[
'\u{00A0}', '\u{1680}', '\u{2000}', '\u{2001}', '\u{2002}', '\u{2003}', '\u{2004}', '\u{2005}',
'\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200A}', '\u{200B}', '\u{202F}', '\u{205F}',
'\u{3000}',
];
const MAPPED_TO_NOTHING: &[char] = &[
'\u{00AD}', '\u{034F}', '\u{1806}', '\u{180B}', '\u{180C}', '\u{180D}', '\u{200B}', '\u{200C}',
'\u{200D}', '\u{2060}', '\u{FE00}', '\u{FE01}', '\u{FE02}', '\u{FE03}', '\u{FE04}', '\u{FE05}',
'\u{FE06}', '\u{FE07}', '\u{FE08}', '\u{FE09}', '\u{FE0A}', '\u{FE0B}', '\u{FE0C}', '\u{FE0D}',
'\u{FE0E}', '\u{FE0F}', '\u{FEFF}',
];
#[must_use]
pub(crate) fn saslprep(password: &str) -> Option<String> {
let mapped: String = password
.chars()
.filter_map(|c| {
if NON_ASCII_SPACES.contains(&c) {
Some(' ')
} else if MAPPED_TO_NOTHING.contains(&c) {
None
} else {
Some(c)
}
})
.collect();
let normalized: String = mapped.nfkc().collect();
if normalized.chars().any(is_prohibited) {
return None;
}
if !bidi_ok(&normalized) {
return None;
}
Some(normalized)
}
fn is_prohibited(c: char) -> bool {
if NON_ASCII_SPACES.contains(&c) {
return true;
}
let code = u32::from(c);
let c21 = matches!(code, 0x0000..=0x001F | 0x007F);
let c22 = matches!(code,
0x0080..=0x009F | 0x06DD | 0x070F | 0x180E | 0x200C | 0x200D
| 0x2028 | 0x2029 | 0x2060..=0x2063 | 0x206A..=0x206F | 0xFEFF
| 0xFFF9..=0xFFFC | 0x1D173..=0x1D17A);
let c3 = matches!(code, 0xE000..=0xF8FF | 0xF_0000..=0xF_FFFD | 0x0010_0000..=0x0010_FFFD);
let c4 = matches!(code, 0xFDD0..=0xFDEF) || (code & 0xFFFE) == 0xFFFE;
let c5 = matches!(code, 0xD800..=0xDFFF);
let c6 = matches!(code, 0xFFF9..=0xFFFD);
let c7 = matches!(code, 0x2FF0..=0x2FFB);
let c8 = matches!(code, 0x0340 | 0x0341 | 0x200E | 0x200F | 0x202A..=0x202E | 0x206A..=0x206F);
let c9 = matches!(code, 0xE0001 | 0xE0020..=0xE007F);
c21 || c22 || c3 || c4 || c5 || c6 || c7 || c8 || c9
}
fn bidi_ok(s: &str) -> bool {
if !s.chars().any(is_rand_al_cat) {
return true;
}
if s.chars().any(is_l_cat) {
return false;
}
let mut chars = s.chars();
let first = chars.next();
let last = chars.next_back().or(first);
first.is_some_and(is_rand_al_cat) && last.is_some_and(is_rand_al_cat)
}
fn is_rand_al_cat(c: char) -> bool {
matches!(u32::from(c),
0x05BE | 0x05C0 | 0x05C3 | 0x05D0..=0x05EA | 0x05F0..=0x05F4
| 0x061B | 0x061F | 0x0621..=0x063A | 0x0640..=0x064A
| 0x066D..=0x066F | 0x0671..=0x06D5 | 0x06DD | 0x06E5 | 0x06E6
| 0x06FA..=0x06FE | 0x0700..=0x070D | 0x0710 | 0x0712..=0x072C
| 0x0780..=0x07A5 | 0x07B1 | 0x200F | 0xFB1D | 0xFB1F..=0xFB28
| 0xFB2A..=0xFB36 | 0xFB38..=0xFB3C | 0xFB3E | 0xFB40 | 0xFB41
| 0xFB43 | 0xFB44 | 0xFB46..=0xFBB1 | 0xFBD3..=0xFD3D
| 0xFD50..=0xFD8F | 0xFD92..=0xFDC7 | 0xFDF0..=0xFDFC
| 0xFE70..=0xFE74 | 0xFE76..=0xFEFC)
}
fn is_l_cat(c: char) -> bool {
if is_rand_al_cat(c) {
return false;
}
if c.is_ascii_digit() || c.is_whitespace() || c.is_ascii_punctuation() {
return false;
}
if u32::from(c) < 0x0041 {
return false;
}
c.is_alphabetic() || c.is_numeric()
}
#[cfg(test)]
mod tests {
use super::saslprep;
#[test]
fn the_rfc_4013_examples() {
assert_eq!(saslprep("I\u{00AD}X").as_deref(), Some("IX"));
assert_eq!(saslprep("user").as_deref(), Some("user"));
assert_eq!(saslprep("USER").as_deref(), Some("USER"));
assert_eq!(saslprep("\u{00AA}").as_deref(), Some("a"));
assert_eq!(saslprep("\u{2168}").as_deref(), Some("IX"));
assert_eq!(saslprep("\u{0007}"), None);
assert_eq!(saslprep("\u{0627}\u{0031}"), None);
}
#[test]
fn a_non_ascii_space_becomes_u0020() {
assert_eq!(saslprep("a\u{00A0}b").as_deref(), Some("a b"));
assert_eq!(saslprep("a\u{3000}b").as_deref(), Some("a b"));
assert_eq!(saslprep("a\u{200B}b").as_deref(), Some("a b"));
}
#[test]
fn nfkc_composes_a_decomposed_sequence() {
assert_eq!(
saslprep("cafe\u{0301}").as_deref(),
saslprep("caf\u{00E9}").as_deref()
);
assert_eq!(saslprep("cafe\u{0301}").as_deref(), Some("caf\u{00E9}"));
}
#[test]
fn an_all_arabic_password_passes_the_bidi_rule() {
assert_eq!(
saslprep("\u{0627}\u{0628}").as_deref(),
Some("\u{0627}\u{0628}")
);
}
#[test]
fn mixing_the_two_directions_fails() {
assert_eq!(saslprep("\u{0627}a"), None);
assert_eq!(saslprep("a\u{0627}"), None);
}
#[test]
fn a_purely_latin_password_never_reaches_the_bidi_rule() {
assert_eq!(saslprep("pa55w0rd!").as_deref(), Some("pa55w0rd!"));
}
#[test]
fn the_empty_password_prepares_to_itself() {
assert_eq!(saslprep("").as_deref(), Some(""));
}
#[test]
fn every_prohibited_table_names_a_member() {
for (table, c) in [
("C.1.2", '\u{00A0}'),
("C.2.1", '\u{0007}'),
("C.2.2", '\u{0085}'),
("C.3", '\u{E000}'),
("C.4", '\u{FDD0}'),
("C.4 plane end", '\u{FFFE}'),
("C.5", '\u{D7FF}'),
("C.6", '\u{FFFD}'),
("C.7", '\u{2FF0}'),
("C.8", '\u{202A}'),
("C.9", '\u{E0020}'),
] {
let expected = table != "C.5";
assert_eq!(
super::is_prohibited(c),
expected,
"{table} U+{:04X}",
u32::from(c)
);
}
}
#[test]
fn a_prohibited_character_makes_the_whole_password_unpreparable() {
for c in ['\u{0007}', '\u{0085}', '\u{E000}', '\u{2FF0}', '\u{202A}'] {
assert_eq!(
saslprep(&format!("pass{c}word")),
None,
"{:04X}",
u32::from(c)
);
}
}
}