use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::dns::DnsPresence;
use crate::domain_info::{DomainInfo, DomainInfoSource};
use crate::error::Result;
use crate::lookup::SmartLookup;
use crate::validation::normalize_domain;
const MAX_CANDIDATES: usize = 600;
const PREFILTER_CONCURRENCY: usize = 50;
const SWAP_TLDS: &[&str] = &[
"com", "net", "org", "co", "io", "info", "biz", "app", "dev", "xyz", "online", "site",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConfusableCandidate {
pub domain: String,
pub technique: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisteredLookalike {
pub domain: String,
pub technique: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrar: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub creation_date: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub nameservers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfusableReport {
pub domain: String,
pub candidates_generated: usize,
pub candidates_checked: usize,
pub registered: Vec<RegisteredLookalike>,
}
fn keyboard_neighbors(c: char) -> &'static [char] {
match c {
'a' => &['q', 'w', 's', 'z'],
'b' => &['v', 'g', 'h', 'n'],
'c' => &['x', 'd', 'f', 'v'],
'd' => &['s', 'e', 'f', 'c', 'x'],
'e' => &['w', 'r', 'd', 's'],
'f' => &['d', 'r', 'g', 'v', 'c'],
'g' => &['f', 't', 'h', 'b', 'v'],
'h' => &['g', 'y', 'j', 'n', 'b'],
'i' => &['u', 'o', 'k', 'j'],
'j' => &['h', 'u', 'k', 'm', 'n'],
'k' => &['j', 'i', 'l', 'm'],
'l' => &['k', 'o', 'p'],
'm' => &['n', 'j', 'k'],
'n' => &['b', 'h', 'j', 'm'],
'o' => &['i', 'p', 'l', 'k'],
'p' => &['o', 'l'],
'q' => &['w', 'a'],
'r' => &['e', 't', 'f', 'd'],
's' => &['a', 'w', 'd', 'x', 'z'],
't' => &['r', 'y', 'g', 'f'],
'u' => &['y', 'i', 'j', 'h'],
'v' => &['c', 'f', 'g', 'b'],
'w' => &['q', 'e', 's', 'a'],
'x' => &['z', 's', 'd', 'c'],
'y' => &['t', 'u', 'h', 'g'],
'z' => &['a', 's', 'x'],
_ => &[],
}
}
fn homoglyphs(c: char) -> &'static [&'static str] {
match c {
'o' => &["0"],
'0' => &["o"],
'l' => &["1", "i"],
'i' => &["1", "l"],
'1' => &["l", "i"],
'e' => &["3"],
'a' => &["4"],
's' => &["5"],
'b' => &["6"],
't' => &["7"],
'g' => &["9"],
'm' => &["rn"],
'w' => &["vv"],
_ => &[],
}
}
fn is_ldh(c: char) -> bool {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
}
pub fn generate_candidates(domain: &str) -> Vec<ConfusableCandidate> {
let Ok(normalized) = normalize_domain(domain) else {
return Vec::new();
};
let Some((prefix, tld)) = normalized.rsplit_once('.') else {
return Vec::new();
};
let (sub, label) = match prefix.rsplit_once('.') {
Some((sub, label)) => (Some(sub), label),
None => (None, prefix),
};
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
let mut push = |label_variant: String, technique: &str, out: &mut Vec<ConfusableCandidate>| {
if label_variant.is_empty()
|| label_variant.starts_with('-')
|| label_variant.ends_with('-')
{
return;
}
let candidate = match sub {
Some(sub) => format!("{sub}.{label_variant}.{tld}"),
None => format!("{label_variant}.{tld}"),
};
if candidate != normalized && seen.insert(candidate.clone()) {
out.push(ConfusableCandidate {
domain: candidate,
technique: technique.to_string(),
});
}
};
let chars: Vec<char> = label.chars().collect();
for i in 0..chars.len() {
let mut v = chars.clone();
v.remove(i);
push(v.into_iter().collect(), "omission", &mut out);
}
for i in 0..chars.len().saturating_sub(1) {
let mut v = chars.clone();
v.swap(i, i + 1);
push(v.into_iter().collect(), "transposition", &mut out);
}
for i in 0..chars.len() {
let mut v = chars.clone();
v.insert(i, chars[i]);
push(v.into_iter().collect(), "repetition", &mut out);
}
for (i, &c) in chars.iter().enumerate() {
for &n in keyboard_neighbors(c) {
let mut v = chars.clone();
v[i] = n;
push(v.into_iter().collect(), "replacement", &mut out);
}
}
for i in 0..=chars.len() {
for n in b'a'..=b'z' {
let mut v = chars.clone();
v.insert(i, n as char);
push(v.into_iter().collect(), "insertion", &mut out);
}
}
for (i, &c) in chars.iter().enumerate() {
if !c.is_ascii() {
continue;
}
for bit in 0..7 {
let flipped = (c as u8) ^ (1 << bit);
let fc = flipped as char;
if is_ldh(fc) && fc != c {
let mut v = chars.clone();
v[i] = fc;
push(v.into_iter().collect(), "bitsquat", &mut out);
}
}
}
for (i, &c) in chars.iter().enumerate() {
for &sub_str in homoglyphs(c) {
let mut variant = String::new();
for (j, &cc) in chars.iter().enumerate() {
if i == j {
variant.push_str(sub_str);
} else {
variant.push(cc);
}
}
push(variant, "homoglyph", &mut out);
}
}
for &swap in SWAP_TLDS {
if swap != tld {
let candidate = match sub {
Some(sub) => format!("{sub}.{label}.{swap}"),
None => format!("{label}.{swap}"),
};
if candidate != normalized && seen.insert(candidate.clone()) {
out.push(ConfusableCandidate {
domain: candidate,
technique: "tld-swap".to_string(),
});
}
}
}
out.truncate(MAX_CANDIDATES);
out
}
fn survives_prefilter(presence: DnsPresence) -> bool {
!matches!(presence, DnsPresence::Absent)
}
pub async fn score_candidates(
lookup: &SmartLookup,
candidates: Vec<ConfusableCandidate>,
concurrency: usize,
) -> (Vec<RegisteredLookalike>, usize) {
use futures::stream::{self, StreamExt};
let concurrency = concurrency.max(1);
let prefilter_concurrency = concurrency.max(PREFILTER_CONCURRENCY);
let survivors: Vec<ConfusableCandidate> = stream::iter(candidates)
.map(|cand| async move {
survives_prefilter(lookup.presence(&cand.domain).await).then_some(cand)
})
.buffer_unordered(prefilter_concurrency)
.filter_map(|c| async move { c })
.collect()
.await;
let candidates_checked = survivors.len();
let mut registered: Vec<RegisteredLookalike> = stream::iter(survivors)
.map(|cand| async move {
let result = lookup.lookup(&cand.domain).await.ok()?;
let info = DomainInfo::from_lookup_result(&result);
if info.source == DomainInfoSource::Available {
return None;
}
Some(RegisteredLookalike {
domain: cand.domain,
technique: cand.technique,
registrar: info.registrar,
creation_date: info.creation_date,
nameservers: info.nameservers,
})
})
.buffer_unordered(concurrency)
.filter_map(|r| async move { r })
.collect()
.await;
registered.sort_by(|a, b| match (b.creation_date, a.creation_date) {
(Some(bd), Some(ad)) => bd.cmp(&ad),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.domain.cmp(&b.domain),
});
(registered, candidates_checked)
}
pub async fn find_confusables(
lookup: &SmartLookup,
domain: &str,
concurrency: usize,
) -> Result<ConfusableReport> {
let domain = normalize_domain(domain)?;
let candidates = generate_candidates(&domain);
let candidates_generated = candidates.len();
let (registered, candidates_checked) = score_candidates(lookup, candidates, concurrency).await;
Ok(ConfusableReport {
domain,
candidates_generated,
candidates_checked,
registered,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn domains(candidates: &[ConfusableCandidate]) -> Vec<&str> {
candidates.iter().map(|c| c.domain.as_str()).collect()
}
#[test]
fn generates_omission_transposition_and_tld_swaps() {
let cands = generate_candidates("example.com");
let d = domains(&cands);
assert!(d.contains(&"xample.com"), "omission missing");
assert!(d.contains(&"eaxmple.com"), "transposition missing");
assert!(d.contains(&"example.net"), "tld-swap missing");
assert!(d.contains(&"3xample.com"), "homoglyph missing");
}
#[test]
fn never_includes_the_original_and_is_deduped() {
let cands = generate_candidates("example.com");
assert!(!cands.iter().any(|c| c.domain == "example.com"));
let mut uniq = std::collections::HashSet::new();
for c in &cands {
assert!(uniq.insert(&c.domain), "duplicate candidate: {}", c.domain);
}
}
#[test]
fn preserves_subdomains_and_only_permutes_registrable_label() {
let cands = generate_candidates("mail.example.com");
assert!(cands
.iter()
.any(|c| c.domain == "mail.xample.com" && c.technique == "omission"));
assert!(cands
.iter()
.all(|c| c.domain.starts_with("mail.") || c.technique == "tld-swap"));
}
#[test]
fn candidate_count_is_capped() {
let cands = generate_candidates("averylongexamplelabelname.com");
assert!(cands.len() <= MAX_CANDIDATES);
}
#[test]
fn variants_are_valid_labels() {
for c in generate_candidates("ab.com") {
let label = c.domain.rsplit_once('.').unwrap().0;
assert!(!label.is_empty());
assert!(!label.starts_with('-') && !label.ends_with('-'));
}
}
#[test]
fn single_label_input_yields_nothing() {
assert!(generate_candidates("localhost").is_empty());
}
#[test]
fn prefilter_drops_only_nxdomain_candidates() {
assert!(!survives_prefilter(DnsPresence::Absent));
assert!(survives_prefilter(DnsPresence::Present));
assert!(survives_prefilter(DnsPresence::Unknown));
}
}