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 push_unique(
150 seen: &mut HashSet<String>,
151 bucket: &mut Vec<ConfusableCandidate>,
152 original: &str,
153 candidate: String,
154 technique: &str,
155) {
156 if candidate != original && seen.insert(candidate.clone()) {
157 bucket.push(ConfusableCandidate {
158 domain: candidate,
159 technique: technique.to_string(),
160 });
161 }
162}
163
164fn fair_shares(sizes: &[usize], cap: usize) -> Vec<usize> {
170 let mut order: Vec<usize> = (0..sizes.len()).collect();
171 order.sort_by_key(|&i| sizes[i]);
172 let mut shares = vec![0; sizes.len()];
173 let mut remaining = cap;
174 for (k, &i) in order.iter().enumerate() {
175 let take = sizes[i].min(remaining / (sizes.len() - k));
176 shares[i] = take;
177 remaining -= take;
178 }
179 shares
180}
181
182pub fn generate_candidates(domain: &str) -> Vec<ConfusableCandidate> {
195 let Ok(normalized) = normalize_domain(domain) else {
196 return Vec::new();
197 };
198 let Some(tld) = crate::psl::public_suffix(&normalized) else {
199 return Vec::new();
200 };
201 let Some(prefix) = normalized
202 .strip_suffix(tld)
203 .and_then(|p| p.strip_suffix('.'))
204 .filter(|p| !p.is_empty())
205 else {
206 return Vec::new();
207 };
208 let (sub, label) = match prefix.rsplit_once('.') {
211 Some((sub, label)) => (Some(sub), label),
212 None => (None, prefix),
213 };
214
215 let with_label = |variant: &str| -> Option<String> {
218 if variant.is_empty() || variant.starts_with('-') || variant.ends_with('-') {
219 return None;
220 }
221 Some(match sub {
222 Some(sub) => format!("{sub}.{variant}.{tld}"),
223 None => format!("{variant}.{tld}"),
224 })
225 };
226
227 let mut seen = HashSet::new();
228 let mut buckets: Vec<Vec<ConfusableCandidate>> = Vec::new();
231 let mut emit = |technique: &str, variants: Vec<String>| {
232 let mut bucket = Vec::new();
233 for variant in variants {
234 if let Some(candidate) = with_label(&variant) {
235 push_unique(&mut seen, &mut bucket, &normalized, candidate, technique);
236 }
237 }
238 buckets.push(bucket);
239 };
240
241 let chars: Vec<char> = label.chars().collect();
242 let join_chars = |v: Vec<char>| v.into_iter().collect::<String>();
243
244 emit(
246 "omission",
247 (0..chars.len())
248 .map(|i| {
249 let mut v = chars.clone();
250 v.remove(i);
251 join_chars(v)
252 })
253 .collect(),
254 );
255
256 emit(
258 "transposition",
259 (0..chars.len().saturating_sub(1))
260 .map(|i| {
261 let mut v = chars.clone();
262 v.swap(i, i + 1);
263 join_chars(v)
264 })
265 .collect(),
266 );
267
268 emit(
270 "repetition",
271 (0..chars.len())
272 .map(|i| {
273 let mut v = chars.clone();
274 v.insert(i, chars[i]);
275 join_chars(v)
276 })
277 .collect(),
278 );
279
280 let mut replacements = Vec::new();
282 for (i, &c) in chars.iter().enumerate() {
283 for &n in keyboard_neighbors(c) {
284 let mut v = chars.clone();
285 v[i] = n;
286 replacements.push(join_chars(v));
287 }
288 }
289 emit("replacement", replacements);
290
291 let mut insertions = Vec::new();
293 for i in 0..=chars.len() {
294 for n in b'a'..=b'z' {
295 let mut v = chars.clone();
296 v.insert(i, n as char);
297 insertions.push(join_chars(v));
298 }
299 }
300 emit("insertion", insertions);
301
302 let mut bitsquats = Vec::new();
304 for (i, &c) in chars.iter().enumerate() {
305 if !c.is_ascii() {
306 continue;
307 }
308 for bit in 0..7 {
309 let fc = ((c as u8) ^ (1 << bit)) as char;
310 if is_ldh(fc) && fc != c {
311 let mut v = chars.clone();
312 v[i] = fc;
313 bitsquats.push(join_chars(v));
314 }
315 }
316 }
317 emit("bitsquat", bitsquats);
318
319 let mut glyphs = Vec::new();
321 for (i, &c) in chars.iter().enumerate() {
322 for &sub_str in homoglyphs(c) {
323 let mut variant = String::new();
324 for (j, &cc) in chars.iter().enumerate() {
325 if i == j {
326 variant.push_str(sub_str);
327 } else {
328 variant.push(cc);
329 }
330 }
331 glyphs.push(variant);
332 }
333 }
334 emit("homoglyph", glyphs);
335
336 let mut swaps = Vec::new();
338 for &swap in SWAP_TLDS {
339 if swap != tld {
340 let candidate = match sub {
341 Some(sub) => format!("{sub}.{label}.{swap}"),
342 None => format!("{label}.{swap}"),
343 };
344 push_unique(&mut seen, &mut swaps, &normalized, candidate, "tld-swap");
345 }
346 }
347 buckets.push(swaps);
348
349 let sizes: Vec<usize> = buckets.iter().map(Vec::len).collect();
356 let shares = fair_shares(&sizes, MAX_CANDIDATES);
357 buckets
358 .into_iter()
359 .zip(shares)
360 .flat_map(|(mut bucket, share)| {
361 bucket.truncate(share);
362 bucket
363 })
364 .collect()
365}
366
367fn survives_prefilter(presence: DnsPresence) -> bool {
372 !matches!(presence, DnsPresence::Absent)
373}
374
375fn lookalike_from_result(
385 cand: ConfusableCandidate,
386 result: &LookupResult,
387) -> Option<RegisteredLookalike> {
388 if let LookupResult::Available { data, .. } = result {
389 if data.available {
390 return None;
391 }
392 }
393 let info = DomainInfo::from_lookup_result(result);
394 Some(RegisteredLookalike {
395 domain: cand.domain,
396 technique: cand.technique,
397 registrar: info.registrar,
398 creation_date: info.creation_date,
399 nameservers: info.nameservers,
400 })
401}
402
403fn rank_lookalikes(registered: &mut [RegisteredLookalike]) {
406 registered.sort_by(|a, b| match (a.creation_date, b.creation_date) {
407 (Some(ad), Some(bd)) => bd.cmp(&ad).then_with(|| a.domain.cmp(&b.domain)),
408 (Some(_), None) => std::cmp::Ordering::Less,
409 (None, Some(_)) => std::cmp::Ordering::Greater,
410 (None, None) => a.domain.cmp(&b.domain),
411 });
412}
413
414pub async fn score_candidates(
430 lookup: &SmartLookup,
431 candidates: Vec<ConfusableCandidate>,
432 concurrency: usize,
433) -> (Vec<RegisteredLookalike>, usize) {
434 use futures::stream::{self, StreamExt};
435
436 let concurrency = concurrency.max(1);
437
438 let prefilter_concurrency = concurrency.max(PREFILTER_CONCURRENCY);
442 let survivors: Vec<ConfusableCandidate> = stream::iter(candidates)
443 .map(|cand| async move {
444 survives_prefilter(lookup.presence(&cand.domain).await).then_some(cand)
445 })
446 .buffer_unordered(prefilter_concurrency)
447 .filter_map(|c| async move { c })
448 .collect()
449 .await;
450
451 let candidates_checked = survivors.len();
452
453 let mut registered: Vec<RegisteredLookalike> = stream::iter(survivors)
454 .map(|cand| async move {
455 let result = lookup.lookup(&cand.domain).await.ok()?;
456 lookalike_from_result(cand, &result)
457 })
458 .buffer_unordered(concurrency)
459 .filter_map(|r| async move { r })
460 .collect()
461 .await;
462
463 rank_lookalikes(&mut registered);
466 (registered, candidates_checked)
467}
468
469pub async fn find_confusables(
472 lookup: &SmartLookup,
473 domain: &str,
474 concurrency: usize,
475) -> Result<ConfusableReport> {
476 let domain = normalize_domain(domain)?;
477 let candidates = generate_candidates(&domain);
478 let candidates_generated = candidates.len();
479 let (registered, candidates_checked) = score_candidates(lookup, candidates, concurrency).await;
480 Ok(ConfusableReport {
481 domain,
482 candidates_generated,
483 candidates_checked,
484 registered,
485 })
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 fn domains(candidates: &[ConfusableCandidate]) -> Vec<&str> {
493 candidates.iter().map(|c| c.domain.as_str()).collect()
494 }
495
496 #[test]
497 fn generates_omission_transposition_and_tld_swaps() {
498 let cands = generate_candidates("example.com");
499 let d = domains(&cands);
500 assert!(d.contains(&"xample.com"), "omission missing");
502 assert!(d.contains(&"eaxmple.com"), "transposition missing");
504 assert!(d.contains(&"example.net"), "tld-swap missing");
506 assert!(d.contains(&"3xample.com"), "homoglyph missing");
508 }
509
510 #[test]
511 fn never_includes_the_original_and_is_deduped() {
512 let cands = generate_candidates("example.com");
513 assert!(!cands.iter().any(|c| c.domain == "example.com"));
514 let mut uniq = HashSet::new();
515 for c in &cands {
516 assert!(uniq.insert(&c.domain), "duplicate candidate: {}", c.domain);
517 }
518 }
519
520 #[test]
521 fn preserves_subdomains_and_only_permutes_registrable_label() {
522 let cands = generate_candidates("mail.example.com");
523 assert!(cands
526 .iter()
527 .any(|c| c.domain == "mail.xample.com" && c.technique == "omission"));
528 assert!(cands
529 .iter()
530 .all(|c| c.domain.starts_with("mail.") || c.technique == "tld-swap"));
531 }
532
533 #[test]
534 fn candidate_count_is_capped() {
535 let cands = generate_candidates("averylongexamplelabelname.com");
537 assert!(cands.len() <= MAX_CANDIDATES);
538 }
539
540 #[test]
541 fn tld_swaps_survive_the_cap_for_long_labels() {
542 for domain in ["wellsfargobanking.com", "averylongexamplelabelname.com"] {
547 let cands = generate_candidates(domain);
548 assert!(cands.len() <= MAX_CANDIDATES);
549 let swaps = cands.iter().filter(|c| c.technique == "tld-swap").count();
550 assert_eq!(
551 swaps,
552 SWAP_TLDS.len() - 1, "{domain}: every tld-swap candidate must survive the cap"
554 );
555 }
556 }
557
558 #[test]
559 fn variants_are_valid_labels() {
560 for c in generate_candidates("ab.com") {
562 let label = c.domain.rsplit_once('.').unwrap().0;
563 assert!(!label.is_empty());
564 assert!(!label.starts_with('-') && !label.ends_with('-'));
565 }
566 }
567
568 #[test]
569 fn single_label_input_yields_nothing() {
570 assert!(generate_candidates("localhost").is_empty());
571 }
572
573 #[test]
574 fn prefilter_drops_only_nxdomain_candidates() {
575 assert!(!survives_prefilter(DnsPresence::Absent));
580 assert!(survives_prefilter(DnsPresence::Present));
581 assert!(survives_prefilter(DnsPresence::Unknown));
582 }
583
584 #[test]
587 fn multi_label_suffix_permutes_the_brand_not_the_suffix() {
588 let cands = generate_candidates("example.co.uk");
591 let d = domains(&cands);
592 assert!(d.contains(&"xample.co.uk"), "omission of the brand label");
593 assert!(d.contains(&"3xample.co.uk"), "homoglyph of the brand label");
594 assert!(
595 d.contains(&"example.com"),
596 "tld-swap replaces the whole suffix"
597 );
598 assert!(!d
599 .iter()
600 .any(|c| c.ends_with(".o.uk") || c.ends_with(".oc.uk")));
601 assert!(cands
602 .iter()
603 .all(|c| c.domain.ends_with(".co.uk") || c.technique == "tld-swap"));
604
605 let cands = generate_candidates("shop.example.com.au");
607 assert!(cands
608 .iter()
609 .any(|c| c.domain == "shop.xample.com.au" && c.technique == "omission"));
610 assert!(cands
611 .iter()
612 .all(|c| c.domain.starts_with("shop.") || c.technique == "tld-swap"));
613 }
614
615 #[test]
616 fn bare_public_suffix_yields_nothing() {
617 assert!(generate_candidates("co.uk").is_empty());
618 }
619
620 #[test]
623 fn fair_shares_only_trims_buckets_above_an_equal_split() {
624 assert_eq!(fair_shares(&[5, 500, 10], 100), vec![5, 85, 10]);
626 assert_eq!(fair_shares(&[3, 4], 100), vec![3, 4]);
628 assert_eq!(fair_shares(&[2, 300, 300], 102), vec![2, 50, 50]);
630 assert_eq!(fair_shares(&[], 10), Vec::<usize>::new());
631 }
632
633 #[test]
634 fn every_technique_survives_the_cap() {
635 let cands = generate_candidates("paypalsecurelogin.com");
638 assert_eq!(
639 cands.len(),
640 MAX_CANDIDATES,
641 "long label still fills the cap"
642 );
643 for technique in [
644 "omission",
645 "transposition",
646 "repetition",
647 "replacement",
648 "insertion",
649 "bitsquat",
650 "homoglyph",
651 "tld-swap",
652 ] {
653 assert!(
654 cands.iter().any(|c| c.technique == technique),
655 "{technique} missing after the cap"
656 );
657 }
658 let homoglyph_count = cands.iter().filter(|c| c.technique == "homoglyph").count();
659 assert_eq!(homoglyph_count, 13, "all homoglyph variants are kept");
660 let insertions = cands.iter().filter(|c| c.technique == "insertion").count();
662 assert!(insertions < 26 * ("paypalsecurelogin".len() + 1));
663 }
664
665 fn lookalike(domain: &str, created: Option<&str>) -> RegisteredLookalike {
668 RegisteredLookalike {
669 domain: domain.to_string(),
670 technique: "omission".to_string(),
671 registrar: None,
672 creation_date: created.map(|d| d.parse().expect("valid RFC 3339 date")),
673 nameservers: Vec::new(),
674 }
675 }
676
677 #[test]
678 fn ranking_is_newest_first_with_undated_last() {
679 let mut v = vec![
680 lookalike("undated-b.com", None),
681 lookalike("old.com", Some("2015-01-01T00:00:00Z")),
682 lookalike("undated-a.com", None),
683 lookalike("new.com", Some("2026-01-01T00:00:00Z")),
684 ];
685 rank_lookalikes(&mut v);
686 let order: Vec<&str> = v.iter().map(|l| l.domain.as_str()).collect();
687 assert_eq!(
688 order,
689 vec!["new.com", "old.com", "undated-a.com", "undated-b.com"]
690 );
691 }
692
693 fn available_result(available: bool, confidence: &str, method: &str) -> LookupResult {
696 LookupResult::Available {
697 data: Box::new(crate::availability::AvailabilityResult {
698 domain: "exmple.com".to_string(),
699 available,
700 confidence: confidence.to_string(),
701 method: method.to_string(),
702 details: None,
703 }),
704 rdap_error: String::new(),
705 whois_error: String::new(),
706 whois_data: None,
707 }
708 }
709
710 fn cand() -> ConfusableCandidate {
711 ConfusableCandidate {
712 domain: "exmple.com".to_string(),
713 technique: "omission".to_string(),
714 }
715 }
716
717 #[test]
718 fn registered_verdicts_from_the_availability_path_are_kept() {
719 let kept = lookalike_from_result(cand(), &available_result(false, "high", "dns_present"));
722 assert_eq!(kept.map(|l| l.domain).as_deref(), Some("exmple.com"));
723 assert!(
724 lookalike_from_result(cand(), &available_result(false, "none", "inconclusive"))
725 .is_some()
726 );
727
728 assert!(lookalike_from_result(cand(), &available_result(true, "high", "rdap")).is_none());
730 assert!(
731 lookalike_from_result(cand(), &available_result(true, "medium", "dns_nxdomain"))
732 .is_none()
733 );
734 }
735}