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 for &swap in SWAP_TLDS {
257 if swap != tld {
258 let candidate = match sub {
259 Some(sub) => format!("{sub}.{label}.{swap}"),
260 None => format!("{label}.{swap}"),
261 };
262 if candidate != normalized && seen.insert(candidate.clone()) {
263 out.push(ConfusableCandidate {
264 domain: candidate,
265 technique: "tld-swap".to_string(),
266 });
267 }
268 }
269 }
270
271 out.truncate(MAX_CANDIDATES);
272 out
273}
274
275fn survives_prefilter(presence: DnsPresence) -> bool {
280 !matches!(presence, DnsPresence::Absent)
281}
282
283pub async fn score_candidates(
297 lookup: &SmartLookup,
298 candidates: Vec<ConfusableCandidate>,
299 concurrency: usize,
300) -> (Vec<RegisteredLookalike>, usize) {
301 use futures::stream::{self, StreamExt};
302
303 let concurrency = concurrency.max(1);
304
305 let prefilter_concurrency = concurrency.max(PREFILTER_CONCURRENCY);
309 let survivors: Vec<ConfusableCandidate> = stream::iter(candidates)
310 .map(|cand| async move {
311 survives_prefilter(lookup.presence(&cand.domain).await).then_some(cand)
312 })
313 .buffer_unordered(prefilter_concurrency)
314 .filter_map(|c| async move { c })
315 .collect()
316 .await;
317
318 let candidates_checked = survivors.len();
319
320 let mut registered: Vec<RegisteredLookalike> = stream::iter(survivors)
321 .map(|cand| async move {
322 let result = lookup.lookup(&cand.domain).await.ok()?;
323 let info = DomainInfo::from_lookup_result(&result);
324 if info.source == DomainInfoSource::Available {
325 return None;
326 }
327 Some(RegisteredLookalike {
328 domain: cand.domain,
329 technique: cand.technique,
330 registrar: info.registrar,
331 creation_date: info.creation_date,
332 nameservers: info.nameservers,
333 })
334 })
335 .buffer_unordered(concurrency)
336 .filter_map(|r| async move { r })
337 .collect()
338 .await;
339
340 registered.sort_by(|a, b| match (b.creation_date, a.creation_date) {
343 (Some(bd), Some(ad)) => bd.cmp(&ad),
344 (Some(_), None) => std::cmp::Ordering::Less,
345 (None, Some(_)) => std::cmp::Ordering::Greater,
346 (None, None) => a.domain.cmp(&b.domain),
347 });
348 (registered, candidates_checked)
349}
350
351pub async fn find_confusables(
354 lookup: &SmartLookup,
355 domain: &str,
356 concurrency: usize,
357) -> Result<ConfusableReport> {
358 let domain = normalize_domain(domain)?;
359 let candidates = generate_candidates(&domain);
360 let candidates_generated = candidates.len();
361 let (registered, candidates_checked) = score_candidates(lookup, candidates, concurrency).await;
362 Ok(ConfusableReport {
363 domain,
364 candidates_generated,
365 candidates_checked,
366 registered,
367 })
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn domains(candidates: &[ConfusableCandidate]) -> Vec<&str> {
375 candidates.iter().map(|c| c.domain.as_str()).collect()
376 }
377
378 #[test]
379 fn generates_omission_transposition_and_tld_swaps() {
380 let cands = generate_candidates("example.com");
381 let d = domains(&cands);
382 assert!(d.contains(&"xample.com"), "omission missing");
384 assert!(d.contains(&"eaxmple.com"), "transposition missing");
386 assert!(d.contains(&"example.net"), "tld-swap missing");
388 assert!(d.contains(&"3xample.com"), "homoglyph missing");
390 }
391
392 #[test]
393 fn never_includes_the_original_and_is_deduped() {
394 let cands = generate_candidates("example.com");
395 assert!(!cands.iter().any(|c| c.domain == "example.com"));
396 let mut uniq = std::collections::HashSet::new();
397 for c in &cands {
398 assert!(uniq.insert(&c.domain), "duplicate candidate: {}", c.domain);
399 }
400 }
401
402 #[test]
403 fn preserves_subdomains_and_only_permutes_registrable_label() {
404 let cands = generate_candidates("mail.example.com");
405 assert!(cands
408 .iter()
409 .any(|c| c.domain == "mail.xample.com" && c.technique == "omission"));
410 assert!(cands
411 .iter()
412 .all(|c| c.domain.starts_with("mail.") || c.technique == "tld-swap"));
413 }
414
415 #[test]
416 fn candidate_count_is_capped() {
417 let cands = generate_candidates("averylongexamplelabelname.com");
419 assert!(cands.len() <= MAX_CANDIDATES);
420 }
421
422 #[test]
423 fn variants_are_valid_labels() {
424 for c in generate_candidates("ab.com") {
426 let label = c.domain.rsplit_once('.').unwrap().0;
427 assert!(!label.is_empty());
428 assert!(!label.starts_with('-') && !label.ends_with('-'));
429 }
430 }
431
432 #[test]
433 fn single_label_input_yields_nothing() {
434 assert!(generate_candidates("localhost").is_empty());
435 }
436
437 #[test]
438 fn prefilter_drops_only_nxdomain_candidates() {
439 assert!(!survives_prefilter(DnsPresence::Absent));
444 assert!(survives_prefilter(DnsPresence::Present));
445 assert!(survives_prefilter(DnsPresence::Unknown));
446 }
447}