1use std::collections::HashSet;
32
33use chrono::{DateTime, Utc};
34use serde::{Deserialize, Serialize};
35
36use crate::dns::DnsPresence;
37use crate::domain_info::DomainInfo;
38use crate::error::Result;
39use crate::lookup::{LookupResult, SmartLookup};
40use crate::validation::normalize_domain;
41
42const MAX_CANDIDATES: usize = 600;
45
46const PREFILTER_CONCURRENCY: usize = 50;
50
51const SWAP_TLDS: &[&str] = &[
53 "com", "net", "org", "co", "io", "info", "biz", "app", "dev", "xyz", "online", "site",
54];
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ConfusableCandidate {
59 pub domain: String,
60 pub technique: String,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct RegisteredLookalike {
67 pub domain: String,
68 pub technique: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub registrar: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub creation_date: Option<DateTime<Utc>>,
73 #[serde(skip_serializing_if = "Vec::is_empty")]
74 pub nameservers: Vec<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ConfusableReport {
80 pub domain: String,
81 pub candidates_generated: usize,
82 pub candidates_checked: usize,
83 pub registered: Vec<RegisteredLookalike>,
85}
86
87fn keyboard_neighbors(c: char) -> &'static [char] {
89 match c {
90 'a' => &['q', 'w', 's', 'z'],
91 'b' => &['v', 'g', 'h', 'n'],
92 'c' => &['x', 'd', 'f', 'v'],
93 'd' => &['s', 'e', 'f', 'c', 'x'],
94 'e' => &['w', 'r', 'd', 's'],
95 'f' => &['d', 'r', 'g', 'v', 'c'],
96 'g' => &['f', 't', 'h', 'b', 'v'],
97 'h' => &['g', 'y', 'j', 'n', 'b'],
98 'i' => &['u', 'o', 'k', 'j'],
99 'j' => &['h', 'u', 'k', 'm', 'n'],
100 'k' => &['j', 'i', 'l', 'm'],
101 'l' => &['k', 'o', 'p'],
102 'm' => &['n', 'j', 'k'],
103 'n' => &['b', 'h', 'j', 'm'],
104 'o' => &['i', 'p', 'l', 'k'],
105 'p' => &['o', 'l'],
106 'q' => &['w', 'a'],
107 'r' => &['e', 't', 'f', 'd'],
108 's' => &['a', 'w', 'd', 'x', 'z'],
109 't' => &['r', 'y', 'g', 'f'],
110 'u' => &['y', 'i', 'j', 'h'],
111 'v' => &['c', 'f', 'g', 'b'],
112 'w' => &['q', 'e', 's', 'a'],
113 'x' => &['z', 's', 'd', 'c'],
114 'y' => &['t', 'u', 'h', 'g'],
115 'z' => &['a', 's', 'x'],
116 _ => &[],
117 }
118}
119
120fn homoglyphs(c: char) -> &'static [&'static str] {
122 match c {
123 'o' => &["0"],
124 '0' => &["o"],
125 'l' => &["1", "i"],
126 'i' => &["1", "l"],
127 '1' => &["l", "i"],
128 'e' => &["3"],
129 'a' => &["4"],
130 's' => &["5"],
131 'b' => &["6"],
132 't' => &["7"],
133 'g' => &["9"],
134 'm' => &["rn"],
135 'w' => &["vv"],
136 _ => &[],
137 }
138}
139
140fn is_ldh(c: char) -> bool {
142 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
143}
144
145fn push_unique(
148 seen: &mut HashSet<String>,
149 bucket: &mut Vec<ConfusableCandidate>,
150 original: &str,
151 candidate: String,
152 technique: &str,
153) {
154 if candidate != original && seen.insert(candidate.clone()) {
155 bucket.push(ConfusableCandidate {
156 domain: candidate,
157 technique: technique.to_string(),
158 });
159 }
160}
161
162fn fair_shares(sizes: &[usize], cap: usize) -> Vec<usize> {
168 let mut order: Vec<usize> = (0..sizes.len()).collect();
169 order.sort_by_key(|&i| sizes[i]);
170 let mut shares = vec![0; sizes.len()];
171 let mut remaining = cap;
172 for (k, &i) in order.iter().enumerate() {
173 let take = sizes[i].min(remaining / (sizes.len() - k));
174 shares[i] = take;
175 remaining -= take;
176 }
177 shares
178}
179
180pub fn generate_candidates(domain: &str) -> Vec<ConfusableCandidate> {
192 let Ok(normalized) = normalize_domain(domain) else {
193 return Vec::new();
194 };
195 let Some(tld) = crate::psl::public_suffix(&normalized) else {
196 return Vec::new();
197 };
198 let Some(prefix) = normalized
199 .strip_suffix(tld)
200 .and_then(|p| p.strip_suffix('.'))
201 .filter(|p| !p.is_empty())
202 else {
203 return Vec::new();
204 };
205 let (sub, label) = match prefix.rsplit_once('.') {
208 Some((sub, label)) => (Some(sub), label),
209 None => (None, prefix),
210 };
211
212 let with_label = |variant: &str| -> Option<String> {
215 if variant.is_empty() || variant.starts_with('-') || variant.ends_with('-') {
216 return None;
217 }
218 Some(match sub {
219 Some(sub) => format!("{sub}.{variant}.{tld}"),
220 None => format!("{variant}.{tld}"),
221 })
222 };
223
224 let mut seen = HashSet::new();
225 let mut buckets: Vec<Vec<ConfusableCandidate>> = Vec::new();
228 let mut emit = |technique: &str, variants: Vec<String>| {
229 let mut bucket = Vec::new();
230 for variant in variants {
231 if let Some(candidate) = with_label(&variant) {
232 push_unique(&mut seen, &mut bucket, &normalized, candidate, technique);
233 }
234 }
235 buckets.push(bucket);
236 };
237
238 let chars: Vec<char> = label.chars().collect();
239 let join_chars = |v: Vec<char>| v.into_iter().collect::<String>();
240
241 emit(
243 "omission",
244 (0..chars.len())
245 .map(|i| {
246 let mut v = chars.clone();
247 v.remove(i);
248 join_chars(v)
249 })
250 .collect(),
251 );
252
253 emit(
255 "transposition",
256 (0..chars.len().saturating_sub(1))
257 .map(|i| {
258 let mut v = chars.clone();
259 v.swap(i, i + 1);
260 join_chars(v)
261 })
262 .collect(),
263 );
264
265 emit(
267 "repetition",
268 (0..chars.len())
269 .map(|i| {
270 let mut v = chars.clone();
271 v.insert(i, chars[i]);
272 join_chars(v)
273 })
274 .collect(),
275 );
276
277 let mut replacements = Vec::new();
279 for (i, &c) in chars.iter().enumerate() {
280 for &n in keyboard_neighbors(c) {
281 let mut v = chars.clone();
282 v[i] = n;
283 replacements.push(join_chars(v));
284 }
285 }
286 emit("replacement", replacements);
287
288 let mut insertions = Vec::new();
290 for i in 0..=chars.len() {
291 for n in b'a'..=b'z' {
292 let mut v = chars.clone();
293 v.insert(i, n as char);
294 insertions.push(join_chars(v));
295 }
296 }
297 emit("insertion", insertions);
298
299 let mut bitsquats = Vec::new();
301 for (i, &c) in chars.iter().enumerate() {
302 if !c.is_ascii() {
303 continue;
304 }
305 for bit in 0..7 {
306 let fc = ((c as u8) ^ (1 << bit)) as char;
307 if is_ldh(fc) && fc != c {
308 let mut v = chars.clone();
309 v[i] = fc;
310 bitsquats.push(join_chars(v));
311 }
312 }
313 }
314 emit("bitsquat", bitsquats);
315
316 let mut glyphs = Vec::new();
318 for (i, &c) in chars.iter().enumerate() {
319 for &sub_str in homoglyphs(c) {
320 let mut variant = String::new();
321 for (j, &cc) in chars.iter().enumerate() {
322 if i == j {
323 variant.push_str(sub_str);
324 } else {
325 variant.push(cc);
326 }
327 }
328 glyphs.push(variant);
329 }
330 }
331 emit("homoglyph", glyphs);
332
333 let mut swaps = Vec::new();
335 for &swap in SWAP_TLDS {
336 if swap != tld {
337 let candidate = match sub {
338 Some(sub) => format!("{sub}.{label}.{swap}"),
339 None => format!("{label}.{swap}"),
340 };
341 push_unique(&mut seen, &mut swaps, &normalized, candidate, "tld-swap");
342 }
343 }
344 buckets.push(swaps);
345
346 let sizes: Vec<usize> = buckets.iter().map(Vec::len).collect();
353 let shares = fair_shares(&sizes, MAX_CANDIDATES);
354 buckets
355 .into_iter()
356 .zip(shares)
357 .flat_map(|(mut bucket, share)| {
358 bucket.truncate(share);
359 bucket
360 })
361 .collect()
362}
363
364fn survives_prefilter(presence: DnsPresence) -> bool {
369 !matches!(presence, DnsPresence::Absent)
370}
371
372fn lookalike_from_result(
382 cand: ConfusableCandidate,
383 result: &LookupResult,
384) -> Option<RegisteredLookalike> {
385 if let LookupResult::Available { data, .. } = result {
386 if data.available {
387 return None;
388 }
389 }
390 let info = DomainInfo::from_lookup_result(result);
391 Some(RegisteredLookalike {
392 domain: cand.domain,
393 technique: cand.technique,
394 registrar: info.registrar,
395 creation_date: info.creation_date,
396 nameservers: info.nameservers,
397 })
398}
399
400fn rank_lookalikes(registered: &mut [RegisteredLookalike]) {
403 registered.sort_by(|a, b| match (a.creation_date, b.creation_date) {
404 (Some(ad), Some(bd)) => bd.cmp(&ad).then_with(|| a.domain.cmp(&b.domain)),
405 (Some(_), None) => std::cmp::Ordering::Less,
406 (None, Some(_)) => std::cmp::Ordering::Greater,
407 (None, None) => a.domain.cmp(&b.domain),
408 });
409}
410
411pub async fn score_candidates(
426 lookup: &SmartLookup,
427 candidates: Vec<ConfusableCandidate>,
428 concurrency: usize,
429) -> (Vec<RegisteredLookalike>, usize) {
430 use futures::stream::{self, StreamExt};
431
432 let concurrency = concurrency.max(1);
433
434 let prefilter_concurrency = concurrency.max(PREFILTER_CONCURRENCY);
438 let survivors: Vec<ConfusableCandidate> = stream::iter(candidates)
439 .map(|cand| async move {
440 survives_prefilter(lookup.presence(&cand.domain).await).then_some(cand)
441 })
442 .buffer_unordered(prefilter_concurrency)
443 .filter_map(|c| async move { c })
444 .collect()
445 .await;
446
447 let candidates_checked = survivors.len();
448
449 let mut registered: Vec<RegisteredLookalike> = stream::iter(survivors)
450 .map(|cand| async move {
451 let result = lookup.lookup(&cand.domain).await.ok()?;
452 lookalike_from_result(cand, &result)
453 })
454 .buffer_unordered(concurrency)
455 .filter_map(|r| async move { r })
456 .collect()
457 .await;
458
459 rank_lookalikes(&mut registered);
462 (registered, candidates_checked)
463}
464
465pub async fn find_confusables(
468 lookup: &SmartLookup,
469 domain: &str,
470 concurrency: usize,
471) -> Result<ConfusableReport> {
472 let domain = normalize_domain(domain)?;
473 let candidates = generate_candidates(&domain);
474 let candidates_generated = candidates.len();
475 let (registered, candidates_checked) = score_candidates(lookup, candidates, concurrency).await;
476 Ok(ConfusableReport {
477 domain,
478 candidates_generated,
479 candidates_checked,
480 registered,
481 })
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 fn domains(candidates: &[ConfusableCandidate]) -> Vec<&str> {
489 candidates.iter().map(|c| c.domain.as_str()).collect()
490 }
491
492 #[test]
493 fn generates_omission_transposition_and_tld_swaps() {
494 let cands = generate_candidates("example.com");
495 let d = domains(&cands);
496 assert!(d.contains(&"xample.com"), "omission missing");
498 assert!(d.contains(&"eaxmple.com"), "transposition missing");
500 assert!(d.contains(&"example.net"), "tld-swap missing");
502 assert!(d.contains(&"3xample.com"), "homoglyph missing");
504 }
505
506 #[test]
507 fn never_includes_the_original_and_is_deduped() {
508 let cands = generate_candidates("example.com");
509 assert!(!cands.iter().any(|c| c.domain == "example.com"));
510 let mut uniq = HashSet::new();
511 for c in &cands {
512 assert!(uniq.insert(&c.domain), "duplicate candidate: {}", c.domain);
513 }
514 }
515
516 #[test]
517 fn preserves_subdomains_and_only_permutes_registrable_label() {
518 let cands = generate_candidates("mail.example.com");
519 assert!(cands
522 .iter()
523 .any(|c| c.domain == "mail.xample.com" && c.technique == "omission"));
524 assert!(cands
525 .iter()
526 .all(|c| c.domain.starts_with("mail.") || c.technique == "tld-swap"));
527 }
528
529 #[test]
530 fn candidate_count_is_capped() {
531 let cands = generate_candidates("averylongexamplelabelname.com");
533 assert!(cands.len() <= MAX_CANDIDATES);
534 }
535
536 #[test]
537 fn tld_swaps_survive_the_cap_for_long_labels() {
538 for domain in ["wellsfargobanking.com", "averylongexamplelabelname.com"] {
543 let cands = generate_candidates(domain);
544 assert!(cands.len() <= MAX_CANDIDATES);
545 let swaps = cands.iter().filter(|c| c.technique == "tld-swap").count();
546 assert_eq!(
547 swaps,
548 SWAP_TLDS.len() - 1, "{domain}: every tld-swap candidate must survive the cap"
550 );
551 }
552 }
553
554 #[test]
555 fn variants_are_valid_labels() {
556 for c in generate_candidates("ab.com") {
558 let label = c.domain.rsplit_once('.').unwrap().0;
559 assert!(!label.is_empty());
560 assert!(!label.starts_with('-') && !label.ends_with('-'));
561 }
562 }
563
564 #[test]
565 fn single_label_input_yields_nothing() {
566 assert!(generate_candidates("localhost").is_empty());
567 }
568
569 #[test]
570 fn prefilter_drops_only_nxdomain_candidates() {
571 assert!(!survives_prefilter(DnsPresence::Absent));
576 assert!(survives_prefilter(DnsPresence::Present));
577 assert!(survives_prefilter(DnsPresence::Unknown));
578 }
579
580 #[test]
583 fn multi_label_suffix_permutes_the_brand_not_the_suffix() {
584 let cands = generate_candidates("example.co.uk");
587 let d = domains(&cands);
588 assert!(d.contains(&"xample.co.uk"), "omission of the brand label");
589 assert!(d.contains(&"3xample.co.uk"), "homoglyph of the brand label");
590 assert!(
591 d.contains(&"example.com"),
592 "tld-swap replaces the whole suffix"
593 );
594 assert!(!d
595 .iter()
596 .any(|c| c.ends_with(".o.uk") || c.ends_with(".oc.uk")));
597 assert!(cands
598 .iter()
599 .all(|c| c.domain.ends_with(".co.uk") || c.technique == "tld-swap"));
600
601 let cands = generate_candidates("shop.example.com.au");
603 assert!(cands
604 .iter()
605 .any(|c| c.domain == "shop.xample.com.au" && c.technique == "omission"));
606 assert!(cands
607 .iter()
608 .all(|c| c.domain.starts_with("shop.") || c.technique == "tld-swap"));
609 }
610
611 #[test]
612 fn bare_public_suffix_yields_nothing() {
613 assert!(generate_candidates("co.uk").is_empty());
614 }
615
616 #[test]
619 fn fair_shares_only_trims_buckets_above_an_equal_split() {
620 assert_eq!(fair_shares(&[5, 500, 10], 100), vec![5, 85, 10]);
622 assert_eq!(fair_shares(&[3, 4], 100), vec![3, 4]);
624 assert_eq!(fair_shares(&[2, 300, 300], 102), vec![2, 50, 50]);
626 assert_eq!(fair_shares(&[], 10), Vec::<usize>::new());
627 }
628
629 #[test]
630 fn every_technique_survives_the_cap() {
631 let cands = generate_candidates("paypalsecurelogin.com");
634 assert_eq!(
635 cands.len(),
636 MAX_CANDIDATES,
637 "long label still fills the cap"
638 );
639 for technique in [
640 "omission",
641 "transposition",
642 "repetition",
643 "replacement",
644 "insertion",
645 "bitsquat",
646 "homoglyph",
647 "tld-swap",
648 ] {
649 assert!(
650 cands.iter().any(|c| c.technique == technique),
651 "{technique} missing after the cap"
652 );
653 }
654 let homoglyph_count = cands.iter().filter(|c| c.technique == "homoglyph").count();
655 assert_eq!(homoglyph_count, 13, "all homoglyph variants are kept");
656 let insertions = cands.iter().filter(|c| c.technique == "insertion").count();
658 assert!(insertions < 26 * ("paypalsecurelogin".len() + 1));
659 }
660
661 fn lookalike(domain: &str, created: Option<&str>) -> RegisteredLookalike {
664 RegisteredLookalike {
665 domain: domain.to_string(),
666 technique: "omission".to_string(),
667 registrar: None,
668 creation_date: created.map(|d| d.parse().expect("valid RFC 3339 date")),
669 nameservers: Vec::new(),
670 }
671 }
672
673 #[test]
674 fn ranking_is_newest_first_with_undated_last() {
675 let mut v = vec![
676 lookalike("undated-b.com", None),
677 lookalike("old.com", Some("2015-01-01T00:00:00Z")),
678 lookalike("undated-a.com", None),
679 lookalike("new.com", Some("2026-01-01T00:00:00Z")),
680 ];
681 rank_lookalikes(&mut v);
682 let order: Vec<&str> = v.iter().map(|l| l.domain.as_str()).collect();
683 assert_eq!(
684 order,
685 vec!["new.com", "old.com", "undated-a.com", "undated-b.com"]
686 );
687 }
688
689 fn available_result(available: bool, confidence: &str, method: &str) -> LookupResult {
692 LookupResult::Available {
693 data: Box::new(crate::availability::AvailabilityResult {
694 domain: "exmple.com".to_string(),
695 available,
696 confidence: confidence.to_string(),
697 method: method.to_string(),
698 details: None,
699 }),
700 rdap_error: String::new(),
701 whois_error: String::new(),
702 whois_data: None,
703 }
704 }
705
706 fn cand() -> ConfusableCandidate {
707 ConfusableCandidate {
708 domain: "exmple.com".to_string(),
709 technique: "omission".to_string(),
710 }
711 }
712
713 #[test]
714 fn registered_verdicts_from_the_availability_path_are_kept() {
715 let kept = lookalike_from_result(cand(), &available_result(false, "high", "dns_present"));
718 assert_eq!(kept.map(|l| l.domain).as_deref(), Some("exmple.com"));
719 assert!(
720 lookalike_from_result(cand(), &available_result(false, "none", "inconclusive"))
721 .is_some()
722 );
723
724 assert!(lookalike_from_result(cand(), &available_result(true, "high", "rdap")).is_none());
726 assert!(
727 lookalike_from_result(cand(), &available_result(true, "medium", "dns_nxdomain"))
728 .is_none()
729 );
730 }
731}