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;
46
47const PREFILTER_CONCURRENCY: usize = 50;
52
53const SWAP_TLDS: &[&str] = &[
55 "com", "net", "org", "co", "io", "info", "biz", "app", "dev", "xyz", "online", "site",
56];
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ConfusableCandidate {
61 pub domain: String,
62 pub technique: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct RegisteredLookalike {
69 pub domain: String,
70 pub technique: String,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub registrar: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub creation_date: Option<DateTime<Utc>>,
75 #[serde(skip_serializing_if = "Vec::is_empty")]
76 pub nameservers: Vec<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ConfusableReport {
82 pub domain: String,
83 pub candidates_generated: usize,
84 pub candidates_checked: usize,
85 pub registered: Vec<RegisteredLookalike>,
87}
88
89fn keyboard_neighbors(c: char) -> &'static [char] {
91 match c {
92 'a' => &['q', 'w', 's', 'z'],
93 'b' => &['v', 'g', 'h', 'n'],
94 'c' => &['x', 'd', 'f', 'v'],
95 'd' => &['s', 'e', 'f', 'c', 'x'],
96 'e' => &['w', 'r', 'd', 's'],
97 'f' => &['d', 'r', 'g', 'v', 'c'],
98 'g' => &['f', 't', 'h', 'b', 'v'],
99 'h' => &['g', 'y', 'j', 'n', 'b'],
100 'i' => &['u', 'o', 'k', 'j'],
101 'j' => &['h', 'u', 'k', 'm', 'n'],
102 'k' => &['j', 'i', 'l', 'm'],
103 'l' => &['k', 'o', 'p'],
104 'm' => &['n', 'j', 'k'],
105 'n' => &['b', 'h', 'j', 'm'],
106 'o' => &['i', 'p', 'l', 'k'],
107 'p' => &['o', 'l'],
108 'q' => &['w', 'a'],
109 'r' => &['e', 't', 'f', 'd'],
110 's' => &['a', 'w', 'd', 'x', 'z'],
111 't' => &['r', 'y', 'g', 'f'],
112 'u' => &['y', 'i', 'j', 'h'],
113 'v' => &['c', 'f', 'g', 'b'],
114 'w' => &['q', 'e', 's', 'a'],
115 'x' => &['z', 's', 'd', 'c'],
116 'y' => &['t', 'u', 'h', 'g'],
117 'z' => &['a', 's', 'x'],
118 _ => &[],
119 }
120}
121
122fn homoglyphs(c: char) -> &'static [&'static str] {
124 match c {
125 'o' => &["0"],
126 '0' => &["o"],
127 'l' => &["1", "i"],
128 'i' => &["1", "l"],
129 '1' => &["l", "i"],
130 'e' => &["3"],
131 'a' => &["4"],
132 's' => &["5"],
133 'b' => &["6"],
134 't' => &["7"],
135 'g' => &["9"],
136 'm' => &["rn"],
137 'w' => &["vv"],
138 _ => &[],
139 }
140}
141
142fn is_ldh(c: char) -> bool {
144 c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
145}
146
147fn is_valid_label(label: &str) -> bool {
153 if label.is_empty() || label.len() > 63 || label.starts_with('-') || label.ends_with('-') {
154 return false;
155 }
156 if label.get(2..4) != Some("--") {
157 return true;
158 }
159 if !label.starts_with("xn--") {
160 return false;
161 }
162 let (unicode, decoded) = idna::domain_to_unicode(label);
165 decoded.is_ok()
166 && unicode != label
167 && idna::domain_to_ascii(&unicode).is_ok_and(|ascii| ascii == label)
168}
169
170fn push_unique(
173 seen: &mut HashSet<String>,
174 bucket: &mut Vec<ConfusableCandidate>,
175 original: &str,
176 candidate: String,
177 technique: &str,
178) {
179 if candidate != original && seen.insert(candidate.clone()) {
180 bucket.push(ConfusableCandidate {
181 domain: candidate,
182 technique: technique.to_string(),
183 });
184 }
185}
186
187fn fair_shares(sizes: &[usize], cap: usize) -> Vec<usize> {
193 let mut order: Vec<usize> = (0..sizes.len()).collect();
194 order.sort_by_key(|&i| sizes[i]);
195 let mut shares = vec![0; sizes.len()];
196 let mut remaining = cap;
197 for (k, &i) in order.iter().enumerate() {
198 let take = sizes[i].min(remaining / (sizes.len() - k));
199 shares[i] = take;
200 remaining -= take;
201 }
202 shares
203}
204
205pub fn generate_candidates(domain: &str) -> Vec<ConfusableCandidate> {
223 let Ok(normalized) = normalize_domain(domain) else {
224 return Vec::new();
225 };
226 let Some(registrable) = crate::psl::registrable_domain(&normalized) else {
227 return Vec::new();
228 };
229 let Some((label, tld)) = registrable.split_once('.') else {
232 return Vec::new();
233 };
234
235 let with_label = |variant: &str| -> Option<String> {
238 let valid = is_valid_label(variant) && variant.len() + 1 + tld.len() <= 253;
239 valid.then(|| format!("{variant}.{tld}"))
240 };
241
242 let mut seen = HashSet::new();
243 let mut buckets: Vec<Vec<ConfusableCandidate>> = Vec::new();
246 let mut emit = |technique: &str, variants: Vec<String>| {
247 let mut bucket = Vec::new();
248 for variant in variants {
249 if let Some(candidate) = with_label(&variant) {
250 push_unique(&mut seen, &mut bucket, registrable, candidate, technique);
251 }
252 }
253 buckets.push(bucket);
254 };
255
256 let chars: Vec<char> = label.chars().collect();
257 let join_chars = |v: Vec<char>| v.into_iter().collect::<String>();
258
259 emit(
261 "omission",
262 (0..chars.len())
263 .map(|i| {
264 let mut v = chars.clone();
265 v.remove(i);
266 join_chars(v)
267 })
268 .collect(),
269 );
270
271 emit(
273 "transposition",
274 (0..chars.len().saturating_sub(1))
275 .map(|i| {
276 let mut v = chars.clone();
277 v.swap(i, i + 1);
278 join_chars(v)
279 })
280 .collect(),
281 );
282
283 emit(
285 "repetition",
286 (0..chars.len())
287 .map(|i| {
288 let mut v = chars.clone();
289 v.insert(i, chars[i]);
290 join_chars(v)
291 })
292 .collect(),
293 );
294
295 let mut replacements = Vec::new();
297 for (i, &c) in chars.iter().enumerate() {
298 for &n in keyboard_neighbors(c) {
299 let mut v = chars.clone();
300 v[i] = n;
301 replacements.push(join_chars(v));
302 }
303 }
304 emit("replacement", replacements);
305
306 let mut insertions = Vec::new();
308 for i in 0..=chars.len() {
309 for n in b'a'..=b'z' {
310 let mut v = chars.clone();
311 v.insert(i, n as char);
312 insertions.push(join_chars(v));
313 }
314 }
315 emit("insertion", insertions);
316
317 let mut bitsquats = Vec::new();
320 for (i, &c) in chars.iter().enumerate() {
321 for bit in 0..7 {
322 let fc = ((c as u8) ^ (1 << bit)) as char;
323 if is_ldh(fc) && fc != c {
324 let mut v = chars.clone();
325 v[i] = fc;
326 bitsquats.push(join_chars(v));
327 }
328 }
329 }
330 emit("bitsquat", bitsquats);
331
332 let mut glyphs = Vec::new();
334 for (i, &c) in chars.iter().enumerate() {
335 for &sub_str in homoglyphs(c) {
336 let mut variant = String::new();
337 for (j, &cc) in chars.iter().enumerate() {
338 if i == j {
339 variant.push_str(sub_str);
340 } else {
341 variant.push(cc);
342 }
343 }
344 glyphs.push(variant);
345 }
346 }
347 emit("homoglyph", glyphs);
348
349 let mut swaps = Vec::new();
351 for &swap in SWAP_TLDS {
352 if swap != tld {
353 let candidate = format!("{label}.{swap}");
354 push_unique(&mut seen, &mut swaps, registrable, candidate, "tld-swap");
355 }
356 }
357 buckets.push(swaps);
358
359 let sizes: Vec<usize> = buckets.iter().map(Vec::len).collect();
366 let shares = fair_shares(&sizes, MAX_CANDIDATES);
367 buckets
368 .into_iter()
369 .zip(shares)
370 .flat_map(|(mut bucket, share)| {
371 bucket.truncate(share);
372 bucket
373 })
374 .collect()
375}
376
377fn survives_prefilter(presence: DnsPresence) -> bool {
382 !matches!(presence, DnsPresence::Absent)
383}
384
385fn lookalike_from_result(
395 cand: ConfusableCandidate,
396 result: &LookupResult,
397) -> Option<RegisteredLookalike> {
398 if let LookupResult::Available { data, .. } = result {
399 if data.available {
400 return None;
401 }
402 }
403 let info = DomainInfo::from_lookup_result(result);
404 Some(RegisteredLookalike {
405 domain: cand.domain,
406 technique: cand.technique,
407 registrar: info.registrar,
408 creation_date: info.creation_date,
409 nameservers: info.nameservers,
410 })
411}
412
413fn rank_lookalikes(registered: &mut [RegisteredLookalike]) {
416 registered.sort_by(|a, b| match (a.creation_date, b.creation_date) {
417 (Some(ad), Some(bd)) => bd.cmp(&ad).then_with(|| a.domain.cmp(&b.domain)),
418 (Some(_), None) => std::cmp::Ordering::Less,
419 (None, Some(_)) => std::cmp::Ordering::Greater,
420 (None, None) => a.domain.cmp(&b.domain),
421 });
422}
423
424pub async fn score_candidates(
440 lookup: &SmartLookup,
441 candidates: Vec<ConfusableCandidate>,
442 concurrency: usize,
443) -> (Vec<RegisteredLookalike>, usize) {
444 score_with(lookup, candidates, FanOut::shared(concurrency, 1)).await
445}
446
447#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449struct FanOut {
450 prefilter: usize,
451 lookups: usize,
452}
453
454impl FanOut {
455 fn shared(concurrency: usize, scans: usize) -> Self {
460 let concurrency = concurrency.max(1);
461 let scans = scans.max(1);
462 Self {
463 prefilter: (concurrency.max(PREFILTER_CONCURRENCY) / scans).max(1),
464 lookups: (concurrency / scans).max(1),
465 }
466 }
467}
468
469async fn score_with(
470 lookup: &SmartLookup,
471 candidates: Vec<ConfusableCandidate>,
472 fan_out: FanOut,
473) -> (Vec<RegisteredLookalike>, usize) {
474 use futures::stream::{self, StreamExt};
475
476 let survivors: Vec<ConfusableCandidate> = stream::iter(candidates)
480 .map(|cand| async move {
481 survives_prefilter(lookup.presence(&cand.domain).await).then_some(cand)
482 })
483 .buffer_unordered(fan_out.prefilter)
484 .filter_map(|c| async move { c })
485 .collect()
486 .await;
487
488 let candidates_checked = survivors.len();
489
490 let mut registered: Vec<RegisteredLookalike> = stream::iter(survivors)
491 .map(|cand| async move {
492 let result = lookup.lookup(&cand.domain).await.ok()?;
493 lookalike_from_result(cand, &result)
494 })
495 .buffer_unordered(fan_out.lookups)
496 .filter_map(|r| async move { r })
497 .collect()
498 .await;
499
500 rank_lookalikes(&mut registered);
503 (registered, candidates_checked)
504}
505
506pub async fn find_confusables(
512 lookup: &SmartLookup,
513 domain: &str,
514 concurrency: usize,
515) -> Result<ConfusableReport> {
516 find_confusables_shared(lookup, domain, concurrency, 1).await
517}
518
519pub(crate) async fn find_confusables_shared(
522 lookup: &SmartLookup,
523 domain: &str,
524 concurrency: usize,
525 scans: usize,
526) -> Result<ConfusableReport> {
527 let domain = normalize_domain(domain)?;
528 let candidates = generate_candidates(&domain);
529 let candidates_generated = candidates.len();
530 let (registered, candidates_checked) =
531 score_with(lookup, candidates, FanOut::shared(concurrency, scans)).await;
532 Ok(ConfusableReport {
533 domain,
534 candidates_generated,
535 candidates_checked,
536 registered,
537 })
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 fn domains(candidates: &[ConfusableCandidate]) -> Vec<&str> {
545 candidates.iter().map(|c| c.domain.as_str()).collect()
546 }
547
548 #[test]
549 fn generates_omission_transposition_and_tld_swaps() {
550 let cands = generate_candidates("example.com");
551 let d = domains(&cands);
552 assert!(d.contains(&"xample.com"), "omission missing");
554 assert!(d.contains(&"eaxmple.com"), "transposition missing");
556 assert!(d.contains(&"example.net"), "tld-swap missing");
558 assert!(d.contains(&"3xample.com"), "homoglyph missing");
560 }
561
562 #[test]
563 fn never_includes_the_original_and_is_deduped() {
564 let cands = generate_candidates("example.com");
565 assert!(!cands.iter().any(|c| c.domain == "example.com"));
566 let mut uniq = HashSet::new();
567 for c in &cands {
568 assert!(uniq.insert(&c.domain), "duplicate candidate: {}", c.domain);
569 }
570 }
571
572 #[test]
573 fn subdomain_input_is_scanned_at_its_registrable_domain() {
574 let cands = generate_candidates("mail.example.com");
578 assert_eq!(cands, generate_candidates("example.com"));
579 assert!(cands
580 .iter()
581 .any(|c| c.domain == "xample.com" && c.technique == "omission"));
582 assert!(cands
583 .iter()
584 .any(|c| c.domain == "example.net" && c.technique == "tld-swap"));
585 assert!(cands.iter().all(|c| !c.domain.contains("mail")));
586 assert!(!cands.iter().any(|c| c.domain == "example.com"));
588 }
589
590 #[test]
591 fn candidates_are_valid_dns_names() {
592 let long = format!("{}.com", "a".repeat(63));
594 let cands = generate_candidates(&long);
595 assert!(!cands.is_empty());
596 assert!(cands
597 .iter()
598 .all(|c| c.domain.split('.').all(|l| l.len() <= 63)));
599
600 let cands = generate_candidates("xn--mnchen-3ya.de");
603 assert!(!cands.is_empty());
604 for c in &cands {
605 let label = c.domain.split('.').next().unwrap();
606 if label.get(2..4) == Some("--") {
607 assert!(label.starts_with("xn--"), "{}", c.domain);
608 let (_, ok) = idna::domain_to_unicode(label);
609 assert!(ok.is_ok(), "undecodable A-label {}", c.domain);
610 }
611 }
612 assert!(!cands.iter().any(|c| c.domain.starts_with("nx--")));
613 }
614
615 #[test]
616 fn label_validation_rules() {
617 assert!(is_valid_label("example"));
618 assert!(is_valid_label("ex-ample"));
619 assert!(is_valid_label("xn--mnchen-3ya"));
620 assert!(!is_valid_label(""));
621 assert!(!is_valid_label("-example"));
622 assert!(!is_valid_label("example-"));
623 assert!(!is_valid_label(&"a".repeat(64)));
624 assert!(is_valid_label(&"a".repeat(63)));
625 assert!(!is_valid_label("nx--mnchen-3ya"));
627 assert!(!is_valid_label("ab--cd"));
628 assert!(!is_valid_label("xn--mnchen-3ay"));
631 assert!(!is_valid_label("xn--zzzz"));
632 assert!(!is_valid_label("xn--mnchn-3yae"));
633 assert!(!is_valid_label("xn--abc-"));
634 assert!(!is_valid_label("xn--example-"));
635 }
636
637 #[test]
638 fn fan_out_is_shared_across_concurrent_scans() {
639 assert_eq!(
641 FanOut::shared(10, 1),
642 FanOut {
643 prefilter: 50,
644 lookups: 10
645 }
646 );
647 assert_eq!(
649 FanOut::shared(10, 10),
650 FanOut {
651 prefilter: 5,
652 lookups: 1
653 }
654 );
655 assert_eq!(
656 FanOut::shared(50, 50),
657 FanOut {
658 prefilter: 1,
659 lookups: 1
660 }
661 );
662 assert_eq!(
664 FanOut::shared(0, 0),
665 FanOut {
666 prefilter: 50,
667 lookups: 1
668 }
669 );
670 }
671
672 #[test]
673 fn candidate_count_is_capped() {
674 let cands = generate_candidates("averylongexamplelabelname.com");
676 assert!(cands.len() <= MAX_CANDIDATES);
677 }
678
679 #[test]
680 fn tld_swaps_survive_the_cap_for_long_labels() {
681 for domain in ["wellsfargobanking.com", "averylongexamplelabelname.com"] {
686 let cands = generate_candidates(domain);
687 assert!(cands.len() <= MAX_CANDIDATES);
688 let swaps = cands.iter().filter(|c| c.technique == "tld-swap").count();
689 assert_eq!(
690 swaps,
691 SWAP_TLDS.len() - 1, "{domain}: every tld-swap candidate must survive the cap"
693 );
694 }
695 }
696
697 #[test]
698 fn variants_are_valid_labels() {
699 for c in generate_candidates("ab.com") {
701 let label = c.domain.rsplit_once('.').unwrap().0;
702 assert!(!label.is_empty());
703 assert!(!label.starts_with('-') && !label.ends_with('-'));
704 }
705 }
706
707 #[test]
708 fn single_label_input_yields_nothing() {
709 assert!(generate_candidates("localhost").is_empty());
710 }
711
712 #[test]
713 fn prefilter_drops_only_nxdomain_candidates() {
714 assert!(!survives_prefilter(DnsPresence::Absent));
719 assert!(survives_prefilter(DnsPresence::Present));
720 assert!(survives_prefilter(DnsPresence::Unknown));
721 }
722
723 #[test]
726 fn multi_label_suffix_permutes_the_brand_not_the_suffix() {
727 let cands = generate_candidates("example.co.uk");
730 let d = domains(&cands);
731 assert!(d.contains(&"xample.co.uk"), "omission of the brand label");
732 assert!(d.contains(&"3xample.co.uk"), "homoglyph of the brand label");
733 assert!(
734 d.contains(&"example.com"),
735 "tld-swap replaces the whole suffix"
736 );
737 assert!(!d
738 .iter()
739 .any(|c| c.ends_with(".o.uk") || c.ends_with(".oc.uk")));
740 assert!(cands
741 .iter()
742 .all(|c| c.domain.ends_with(".co.uk") || c.technique == "tld-swap"));
743
744 let cands = generate_candidates("shop.example.com.au");
747 assert!(cands
748 .iter()
749 .any(|c| c.domain == "xample.com.au" && c.technique == "omission"));
750 assert!(cands.iter().all(|c| !c.domain.starts_with("shop.")));
751 }
752
753 #[test]
754 fn bare_public_suffix_yields_nothing() {
755 assert!(generate_candidates("co.uk").is_empty());
756 }
757
758 #[test]
761 fn fair_shares_only_trims_buckets_above_an_equal_split() {
762 assert_eq!(fair_shares(&[5, 500, 10], 100), vec![5, 85, 10]);
764 assert_eq!(fair_shares(&[3, 4], 100), vec![3, 4]);
766 assert_eq!(fair_shares(&[2, 300, 300], 102), vec![2, 50, 50]);
768 assert_eq!(fair_shares(&[], 10), Vec::<usize>::new());
769 }
770
771 #[test]
772 fn every_technique_survives_the_cap() {
773 let cands = generate_candidates("paypalsecurelogin.com");
776 assert_eq!(
777 cands.len(),
778 MAX_CANDIDATES,
779 "long label still fills the cap"
780 );
781 for technique in [
782 "omission",
783 "transposition",
784 "repetition",
785 "replacement",
786 "insertion",
787 "bitsquat",
788 "homoglyph",
789 "tld-swap",
790 ] {
791 assert!(
792 cands.iter().any(|c| c.technique == technique),
793 "{technique} missing after the cap"
794 );
795 }
796 let homoglyph_count = cands.iter().filter(|c| c.technique == "homoglyph").count();
797 assert_eq!(homoglyph_count, 13, "all homoglyph variants are kept");
798 let insertions = cands.iter().filter(|c| c.technique == "insertion").count();
800 assert!(insertions < 26 * ("paypalsecurelogin".len() + 1));
801 }
802
803 fn lookalike(domain: &str, created: Option<&str>) -> RegisteredLookalike {
806 RegisteredLookalike {
807 domain: domain.to_string(),
808 technique: "omission".to_string(),
809 registrar: None,
810 creation_date: created.map(|d| d.parse().expect("valid RFC 3339 date")),
811 nameservers: Vec::new(),
812 }
813 }
814
815 #[test]
816 fn ranking_is_newest_first_with_undated_last() {
817 let mut v = vec![
818 lookalike("undated-b.com", None),
819 lookalike("old.com", Some("2015-01-01T00:00:00Z")),
820 lookalike("undated-a.com", None),
821 lookalike("new.com", Some("2026-01-01T00:00:00Z")),
822 ];
823 rank_lookalikes(&mut v);
824 let order: Vec<&str> = v.iter().map(|l| l.domain.as_str()).collect();
825 assert_eq!(
826 order,
827 vec!["new.com", "old.com", "undated-a.com", "undated-b.com"]
828 );
829 }
830
831 fn available_result(available: bool, confidence: &str, method: &str) -> LookupResult {
834 LookupResult::Available {
835 data: Box::new(crate::availability::AvailabilityResult {
836 domain: "exmple.com".to_string(),
837 available,
838 confidence: confidence.to_string(),
839 method: method.to_string(),
840 details: None,
841 }),
842 rdap_error: String::new(),
843 whois_error: String::new(),
844 whois_data: None,
845 }
846 }
847
848 fn cand() -> ConfusableCandidate {
849 ConfusableCandidate {
850 domain: "exmple.com".to_string(),
851 technique: "omission".to_string(),
852 }
853 }
854
855 #[test]
856 fn registered_verdicts_from_the_availability_path_are_kept() {
857 let kept = lookalike_from_result(cand(), &available_result(false, "high", "dns_present"));
860 assert_eq!(kept.map(|l| l.domain).as_deref(), Some("exmple.com"));
861 assert!(
862 lookalike_from_result(cand(), &available_result(false, "none", "inconclusive"))
863 .is_some()
864 );
865
866 assert!(lookalike_from_result(cand(), &available_result(true, "high", "rdap")).is_none());
868 assert!(
869 lookalike_from_result(cand(), &available_result(true, "medium", "dns_nxdomain"))
870 .is_none()
871 );
872 }
873}