e9495a63ad
link are checking that email matches, +email alias are normalize into email if email is already used on another account, link is not possible not usurpation risk as the IDP is choosen by the admin
167 lines
6.7 KiB
Rust
167 lines
6.7 KiB
Rust
//! Small allocation-free text predicates shared across the hot parse paths.
|
|
|
|
/// ASCII case-insensitive substring test — the allocation-free equivalent of
|
|
/// `haystack_lower.contains(needle_lower)` when both are ASCII.
|
|
///
|
|
/// Callers pass an already-upper/lower-cased `needle` and get the same boolean
|
|
/// `haystack.to_ascii_uppercase().contains(NEEDLE)` would, without the
|
|
/// throwaway per-call `String`. Used by the search name-match classifier and by
|
|
/// `ContactService::parse_vcard`'s per-line `TYPE=` routing
|
|
/// (benches/ROUND20.md §A3).
|
|
pub fn ascii_ci_contains(haystack: &[u8], needle: &[u8]) -> bool {
|
|
if needle.is_empty() {
|
|
return true;
|
|
}
|
|
if needle.len() > haystack.len() {
|
|
return false;
|
|
}
|
|
haystack
|
|
.windows(needle.len())
|
|
.any(|w| w.eq_ignore_ascii_case(needle))
|
|
}
|
|
|
|
/// Normalize an email address for **linking-equivalence comparison**
|
|
/// (NOT for storage — never modify what the user typed when persisting
|
|
/// or displaying).
|
|
///
|
|
/// Rules:
|
|
/// - Case-fold to ASCII lowercase (email addresses are treated as
|
|
/// case-insensitive in practice per RFC 5321 §2.4).
|
|
/// - Strip `+alias` sub-addressing from the local part:
|
|
/// `alice+github@example.com` → `alice@example.com`. Supported by
|
|
/// Gmail / Google Workspace, Outlook/O365 (since 2018), Fastmail
|
|
/// (since ~2020), and most modern providers. Safe for a 1:1
|
|
/// comparison — two same-user addresses normalise to the same value.
|
|
///
|
|
/// NOT doing:
|
|
/// - Dot-stripping (Gmail-only: `a.lice@gmail.com == alice@gmail.com`).
|
|
/// Applying universally would false-positive on providers that treat
|
|
/// dots as significant.
|
|
/// - Unicode normalisation — email addresses compare as ASCII already.
|
|
///
|
|
/// Load-bearing for `POST /api/auth/oidc/link/start` → callback and
|
|
/// for the auto-link decision on OIDC login. See
|
|
/// docs/plan/oidc-account-linking.md § Safety checks.
|
|
pub fn normalize_email_for_link(email: &str) -> String {
|
|
let lower = email.trim().to_ascii_lowercase();
|
|
let Some((local, domain)) = lower.split_once('@') else {
|
|
// Malformed — return the lowercased form; caller's comparison
|
|
// will fail naturally.
|
|
return lower;
|
|
};
|
|
let local_base = local.split_once('+').map(|(b, _)| b).unwrap_or(local);
|
|
format!("{}@{}", local_base, domain)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn matches_uppercase_contains() {
|
|
// Parity with the `to_ascii_uppercase().contains(NEEDLE)` shape it
|
|
// replaced, across mixed case and the empty/oversize edge cases.
|
|
let cases: &[(&str, &str)] = &[
|
|
("EMAIL;TYPE=home:a@b.com", "TYPE=HOME"),
|
|
("EMAIL;type=Work:a@b.com", "TYPE=WORK"),
|
|
("TEL;TYPE=CELL:+1", "TYPE=CELL"),
|
|
("TEL;TYPE=voice:+1", "TYPE=CELL"),
|
|
("ADR;TYPE=Home:;;x", "TYPE=WORK"),
|
|
("", "TYPE=HOME"),
|
|
("short", "a-very-long-needle"),
|
|
];
|
|
for (hay, needle) in cases {
|
|
let reference = hay.to_ascii_uppercase().contains(needle);
|
|
assert_eq!(
|
|
ascii_ci_contains(hay.as_bytes(), needle.as_bytes()),
|
|
reference,
|
|
"mismatch for haystack={hay:?} needle={needle:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_needle_is_true() {
|
|
assert!(ascii_ci_contains(b"anything", b""));
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_email_for_link_matrix() {
|
|
// Behaviour matrix from docs/plan/oidc-account-linking.md
|
|
// § Email normalization. Left = raw, right = expected normalized.
|
|
let cases: &[(&str, &str)] = &[
|
|
// Identity
|
|
("alice@example.com", "alice@example.com"),
|
|
// Case fold
|
|
("Alice@Example.COM", "alice@example.com"),
|
|
// +alias stripped
|
|
("alice+github@example.com", "alice@example.com"),
|
|
("alice+oidc@example.com", "alice@example.com"),
|
|
// Both sides of a match normalise the same way
|
|
("alice+work@example.com", "alice@example.com"),
|
|
// Empty +alias suffix is still stripped
|
|
("alice+@example.com", "alice@example.com"),
|
|
// Multiple + in local: everything after the FIRST + is dropped
|
|
("alice+work+extra@example.com", "alice@example.com"),
|
|
// Trim leading/trailing whitespace
|
|
(" alice@example.com ", "alice@example.com"),
|
|
// Different local parts stay different
|
|
("bob@example.com", "bob@example.com"),
|
|
// Different domains stay different (no cross-domain equivalence)
|
|
("alice@corp.com", "alice@corp.com"),
|
|
// Domain case-folded too
|
|
("alice@Example.COM", "alice@example.com"),
|
|
];
|
|
for (raw, expected) in cases {
|
|
assert_eq!(
|
|
normalize_email_for_link(raw),
|
|
*expected,
|
|
"normalize_email_for_link({raw:?}) should equal {expected:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_email_link_equivalence_pairs() {
|
|
// Anti-drift: pairs that MUST compare equal after normalization
|
|
// (the "auto-link email match" cases the plan doc lists as ✅).
|
|
let equivalent: &[(&str, &str)] = &[
|
|
("alice@example.com", "alice@example.com"),
|
|
("alice@example.com", "Alice@example.com"),
|
|
("alice@example.com", "alice+oidc@example.com"),
|
|
("alice+work@example.com", "alice@example.com"),
|
|
("alice+work@example.com", "alice+home@example.com"),
|
|
];
|
|
for (left, right) in equivalent {
|
|
assert_eq!(
|
|
normalize_email_for_link(left),
|
|
normalize_email_for_link(right),
|
|
"{left:?} should equal {right:?} under linking normalization"
|
|
);
|
|
}
|
|
|
|
// Pairs that MUST NOT match — the plan's ❌ cases.
|
|
let distinct: &[(&str, &str)] = &[
|
|
("alice@example.com", "bob@example.com"),
|
|
("alice@example.com", "alice@corp.com"),
|
|
// Dot-stripping deliberately NOT applied — dots stay significant.
|
|
("a.lice@gmail.com", "alice@gmail.com"),
|
|
];
|
|
for (left, right) in distinct {
|
|
assert_ne!(
|
|
normalize_email_for_link(left),
|
|
normalize_email_for_link(right),
|
|
"{left:?} MUST NOT equal {right:?} — dot-stripping is Gmail-only, we don't apply it"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_email_malformed_returns_lowercased() {
|
|
// No `@` → return lowercased trimmed form; the caller's
|
|
// downstream comparison will fail naturally.
|
|
assert_eq!(normalize_email_for_link("not-an-email"), "not-an-email");
|
|
assert_eq!(normalize_email_for_link(" MIXED-Case "), "mixed-case");
|
|
}
|
|
}
|