1use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32
33use crate::dns::DnsPresence;
34use crate::domain_info::{DomainInfo, DomainInfoSource};
35use crate::error::Result;
36use crate::lookup::SmartLookup;
37use crate::validation::normalize_domain;
38
39const MAX_CANDIDATES: usize = 600;
42
43const PREFILTER_CONCURRENCY: usize = 50;
47
48const SWAP_TLDS: &[&str] = &[
50 "com", "net", "org", "co", "io", "info", "biz", "app", "dev", "xyz", "online", "site",
51];
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ConfusableCandidate {
56 pub domain: String,
57 pub technique: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct RegisteredLookalike {
64 pub domain: String,
65 pub technique: String,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub registrar: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub creation_date: Option<DateTime<Utc>>,
70 #[serde(skip_serializing_if = "Vec::is_empty")]
71 pub nameservers: Vec<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ConfusableReport {
77 pub domain: String,
78 pub candidates_generated: usize,
79 pub candidates_checked: usize,
80 pub registered: Vec<RegisteredLookalike>,
82}
83
84fn keyboard_neighbors(c: char) -> &'static [char] {
86 match c {
87 'a' => &['q', 'w', 's', 'z'],
88 'b' => &['v', 'g', 'h', 'n'],
89 'c' => &['x', 'd', 'f', 'v'],
90 'd' => &['s', 'e', 'f', 'c', 'x'],
91 'e' => &['w', 'r', 'd', 's'],
92 'f' => &['d', 'r', 'g', 'v', 'c'],
93 'g' => &['f', 't', 'h', 'b', 'v'],
94 'h' => &['g', 'y', 'j', 'n', 'b'],
95 'i' => &['u', 'o', 'k', 'j'],
96 'j' => &['h', 'u', 'k', 'm', 'n'],
97 'k' => &['j', 'i', 'l', 'm'],
98 'l' => &['k', 'o', 'p'],
99 'm' => &['n', 'j', 'k'],
100 'n' => &['b', 'h', 'j', 'm'],
101 'o' => &['i', 'p', 'l', 'k'],
102 'p' => &['o', 'l'],
103 'q' => &['w', 'a'],
104 'r' => &['e', 't', 'f', 'd'],
105 's' => &['a', 'w', 'd', 'x', 'z'],
106 't' => &['r', 'y', 'g', 'f'],
107 'u' => &['y', 'i', 'j', 'h'],
108 'v' => &['c', 'f', 'g', 'b'],
109 'w' => &['q', 'e', 's', 'a'],
110 'x' => &['z', 's', 'd', 'c'],
111 'y' => &['t', 'u', 'h', 'g'],
112 'z' => &['a', 's', 'x'],
113 _ => &[],
114 }
115}
116
117fn homoglyphs(c: char) -> &'static [&'static str] {
119 match c {
120 'o' => &["0"],
121 '0' => &["o"],
122 'l' => &["1", "i"],
123 'i' => &["1", "l"],
124 '1' => &["l", "i"],
125 'e' => &["3"],
126 'a' => &["4"],
127 's' => &["5"],
128 'b' => &["6"],
129 't' => &["7"],
130 'g' => &["9"],
131 'm' => &["rn"],
132 'w' => &["vv"],
133 _ => &[],
134 }
135}
136
137fn is_ldh(c: char) -> bool {
139 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
140}
141
142pub fn generate_candidates(domain: &str) -> Vec<ConfusableCandidate> {
149 let Ok(normalized) = normalize_domain(domain) else {
150 return Vec::new();
151 };
152 let Some((prefix, tld)) = normalized.rsplit_once('.') else {
153 return Vec::new();
154 };
155 let (sub, label) = match prefix.rsplit_once('.') {
158 Some((sub, label)) => (Some(sub), label),
159 None => (None, prefix),
160 };
161
162 let mut seen = std::collections::HashSet::new();
163 let mut out = Vec::new();
164 let mut push = |label_variant: String, technique: &str, out: &mut Vec<ConfusableCandidate>| {
165 if label_variant.is_empty()
166 || label_variant.starts_with('-')
167 || label_variant.ends_with('-')
168 {
169 return;
170 }
171 let candidate = match sub {
172 Some(sub) => format!("{sub}.{label_variant}.{tld}"),
173 None => format!("{label_variant}.{tld}"),
174 };
175 if candidate != normalized && seen.insert(candidate.clone()) {
176 out.push(ConfusableCandidate {
177 domain: candidate,
178 technique: technique.to_string(),
179 });
180 }
181 };
182
183 let chars: Vec<char> = label.chars().collect();
184
185 for i in 0..chars.len() {
187 let mut v = chars.clone();
188 v.remove(i);
189 push(v.into_iter().collect(), "omission", &mut out);
190 }
191
192 for i in 0..chars.len().saturating_sub(1) {
194 let mut v = chars.clone();
195 v.swap(i, i + 1);
196 push(v.into_iter().collect(), "transposition", &mut out);
197 }
198
199 for i in 0..chars.len() {
201 let mut v = chars.clone();
202 v.insert(i, chars[i]);
203 push(v.into_iter().collect(), "repetition", &mut out);
204 }
205
206 for (i, &c) in chars.iter().enumerate() {
208 for &n in keyboard_neighbors(c) {
209 let mut v = chars.clone();
210 v[i] = n;
211 push(v.into_iter().collect(), "replacement", &mut out);
212 }
213 }
214
215 for i in 0..=chars.len() {
217 for n in b'a'..=b'z' {
218 let mut v = chars.clone();
219 v.insert(i, n as char);
220 push(v.into_iter().collect(), "insertion", &mut out);
221 }
222 }
223
224 for (i, &c) in chars.iter().enumerate() {
226 if !c.is_ascii() {
227 continue;
228 }
229 for bit in 0..7 {
230 let flipped = (c as u8) ^ (1 << bit);
231 let fc = flipped as char;
232 if is_ldh(fc) && fc != c {
233 let mut v = chars.clone();
234 v[i] = fc;
235 push(v.into_iter().collect(), "bitsquat", &mut out);
236 }
237 }
238 }
239
240 for (i, &c) in chars.iter().enumerate() {
242 for &sub_str in homoglyphs(c) {
243 let mut variant = String::new();
244 for (j, &cc) in chars.iter().enumerate() {
245 if i == j {
246 variant.push_str(sub_str);
247 } else {
248 variant.push(cc);
249 }
250 }
251 push(variant, "homoglyph", &mut out);
252 }
253 }
254
255 let mut swaps = Vec::new();
260 for &swap in SWAP_TLDS {
261 if swap != tld {
262 let candidate = match sub {
263 Some(sub) => format!("{sub}.{label}.{swap}"),
264 None => format!("{label}.{swap}"),
265 };
266 if candidate != normalized && seen.insert(candidate.clone()) {
267 swaps.push(ConfusableCandidate {
268 domain: candidate,
269 technique: "tld-swap".to_string(),
270 });
271 }
272 }
273 }
274
275 out.truncate(MAX_CANDIDATES.saturating_sub(swaps.len()));
278 out.extend(swaps);
279 out
280}
281
282fn survives_prefilter(presence: DnsPresence) -> bool {
287 !matches!(presence, DnsPresence::Absent)
288}
289
290pub async fn score_candidates(
304 lookup: &SmartLookup,
305 candidates: Vec<ConfusableCandidate>,
306 concurrency: usize,
307) -> (Vec<RegisteredLookalike>, usize) {
308 use futures::stream::{self, StreamExt};
309
310 let concurrency = concurrency.max(1);
311
312 let prefilter_concurrency = concurrency.max(PREFILTER_CONCURRENCY);
316 let survivors: Vec<ConfusableCandidate> = stream::iter(candidates)
317 .map(|cand| async move {
318 survives_prefilter(lookup.presence(&cand.domain).await).then_some(cand)
319 })
320 .buffer_unordered(prefilter_concurrency)
321 .filter_map(|c| async move { c })
322 .collect()
323 .await;
324
325 let candidates_checked = survivors.len();
326
327 let mut registered: Vec<RegisteredLookalike> = stream::iter(survivors)
328 .map(|cand| async move {
329 let result = lookup.lookup(&cand.domain).await.ok()?;
330 let info = DomainInfo::from_lookup_result(&result);
331 if info.source == DomainInfoSource::Available {
332 return None;
333 }
334 Some(RegisteredLookalike {
335 domain: cand.domain,
336 technique: cand.technique,
337 registrar: info.registrar,
338 creation_date: info.creation_date,
339 nameservers: info.nameservers,
340 })
341 })
342 .buffer_unordered(concurrency)
343 .filter_map(|r| async move { r })
344 .collect()
345 .await;
346
347 registered.sort_by(|a, b| match (b.creation_date, a.creation_date) {
350 (Some(bd), Some(ad)) => bd.cmp(&ad),
351 (Some(_), None) => std::cmp::Ordering::Less,
352 (None, Some(_)) => std::cmp::Ordering::Greater,
353 (None, None) => a.domain.cmp(&b.domain),
354 });
355 (registered, candidates_checked)
356}
357
358pub async fn find_confusables(
361 lookup: &SmartLookup,
362 domain: &str,
363 concurrency: usize,
364) -> Result<ConfusableReport> {
365 let domain = normalize_domain(domain)?;
366 let candidates = generate_candidates(&domain);
367 let candidates_generated = candidates.len();
368 let (registered, candidates_checked) = score_candidates(lookup, candidates, concurrency).await;
369 Ok(ConfusableReport {
370 domain,
371 candidates_generated,
372 candidates_checked,
373 registered,
374 })
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 fn domains(candidates: &[ConfusableCandidate]) -> Vec<&str> {
382 candidates.iter().map(|c| c.domain.as_str()).collect()
383 }
384
385 #[test]
386 fn generates_omission_transposition_and_tld_swaps() {
387 let cands = generate_candidates("example.com");
388 let d = domains(&cands);
389 assert!(d.contains(&"xample.com"), "omission missing");
391 assert!(d.contains(&"eaxmple.com"), "transposition missing");
393 assert!(d.contains(&"example.net"), "tld-swap missing");
395 assert!(d.contains(&"3xample.com"), "homoglyph missing");
397 }
398
399 #[test]
400 fn never_includes_the_original_and_is_deduped() {
401 let cands = generate_candidates("example.com");
402 assert!(!cands.iter().any(|c| c.domain == "example.com"));
403 let mut uniq = std::collections::HashSet::new();
404 for c in &cands {
405 assert!(uniq.insert(&c.domain), "duplicate candidate: {}", c.domain);
406 }
407 }
408
409 #[test]
410 fn preserves_subdomains_and_only_permutes_registrable_label() {
411 let cands = generate_candidates("mail.example.com");
412 assert!(cands
415 .iter()
416 .any(|c| c.domain == "mail.xample.com" && c.technique == "omission"));
417 assert!(cands
418 .iter()
419 .all(|c| c.domain.starts_with("mail.") || c.technique == "tld-swap"));
420 }
421
422 #[test]
423 fn candidate_count_is_capped() {
424 let cands = generate_candidates("averylongexamplelabelname.com");
426 assert!(cands.len() <= MAX_CANDIDATES);
427 }
428
429 #[test]
430 fn tld_swaps_survive_the_cap_for_long_labels() {
431 for domain in ["wellsfargobanking.com", "averylongexamplelabelname.com"] {
436 let cands = generate_candidates(domain);
437 assert!(cands.len() <= MAX_CANDIDATES);
438 let swaps = cands.iter().filter(|c| c.technique == "tld-swap").count();
439 assert_eq!(
440 swaps,
441 SWAP_TLDS.len() - 1, "{domain}: every tld-swap candidate must survive the cap"
443 );
444 }
445 }
446
447 #[test]
448 fn variants_are_valid_labels() {
449 for c in generate_candidates("ab.com") {
451 let label = c.domain.rsplit_once('.').unwrap().0;
452 assert!(!label.is_empty());
453 assert!(!label.starts_with('-') && !label.ends_with('-'));
454 }
455 }
456
457 #[test]
458 fn single_label_input_yields_nothing() {
459 assert!(generate_candidates("localhost").is_empty());
460 }
461
462 #[test]
463 fn prefilter_drops_only_nxdomain_candidates() {
464 assert!(!survives_prefilter(DnsPresence::Absent));
469 assert!(survives_prefilter(DnsPresence::Present));
470 assert!(survives_prefilter(DnsPresence::Unknown));
471 }
472}