Skip to main content

seer_core/
confusables.rs

1//! Typosquat / homoglyph look-alike generation and registration scoring.
2//!
3//! Given a domain, generates candidate look-alikes (typo permutations plus a
4//! small ASCII-homoglyph map and common TLD swaps) and scores which candidates
5//! are registered, ranking freshly-registered squats first. This is a
6//! brand-protection / phishing-defense capability built entirely on primitives
7//! seer already has (normalization, smart lookup, availability inference) — no
8//! new protocol code. The brand label is found with the Public Suffix List, so
9//! `example.co.uk` permutes `example`, not `co`.
10//!
11//! Candidate generation is pure and unit-tested; only the registration scoring
12//! is async.
13//!
14//! ## DNS presence pre-filter (scoring)
15//!
16//! Most generated candidates are unregistered typos, so before paying for a
17//! full RDAP+WHOIS race on each one, [`score_candidates`] first probes every
18//! candidate with a cheap [`DnsResolver::presence`](crate::dns::DnsResolver::presence)
19//! query and drops the ones that answer `NXDOMAIN` — the same signal the
20//! smart-lookup thin-fallback ladder uses to call an apex unregistered. Only
21//! `Present`/`Unknown` candidates get the full lookup (a failed probe is not
22//! evidence, so it is never skipped).
23//!
24//! Caveat: DNS presence is a cheaper but slightly different signal from a
25//! registry lookup. A registered-but-unresolvable domain only looks `Absent`
26//! via NXDOMAIN, which for a registered name is rare; parked squats are
27//! `Present` and still receive the full lookup. The pre-filter therefore trades
28//! a negligible miss rate for a large reduction in registry queries (a
29//! rate-limit-ban risk against port-43 WHOIS).
30
31use 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
42/// Upper bound on generated candidates, to keep the subsequent network scoring
43/// bounded regardless of label length. The public docs of
44/// [`generate_candidates`] state the value; keep them in step.
45const MAX_CANDIDATES: usize = 600;
46
47/// Concurrency for the cheap DNS presence pre-filter. DNS probes are far
48/// lighter than a full RDAP+WHOIS race, so we fan them out wider than the
49/// (registry-facing) full-lookup concurrency to keep the pre-filter fast.
50/// The public docs of [`score_candidates`] state the value; keep them in step.
51const PREFILTER_CONCURRENCY: usize = 50;
52
53/// Common alternate TLDs used for TLD-swap squats.
54const SWAP_TLDS: &[&str] = &[
55    "com", "net", "org", "co", "io", "info", "biz", "app", "dev", "xyz", "online", "site",
56];
57
58/// A generated look-alike candidate and the technique that produced it.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ConfusableCandidate {
61    pub domain: String,
62    /// The permutation technique (e.g. `omission`, `homoglyph`, `tld-swap`).
63    pub technique: String,
64}
65
66/// A registered look-alike surfaced by scoring.
67#[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/// The result of a confusables scan.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ConfusableReport {
82    pub domain: String,
83    pub candidates_generated: usize,
84    pub candidates_checked: usize,
85    /// Registered look-alikes, most-recently-registered first.
86    pub registered: Vec<RegisteredLookalike>,
87}
88
89/// Returns the QWERTY-adjacent keys for `c` (used for fat-finger substitutions).
90fn 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
122/// Returns ASCII-homoglyph replacement strings for `c` (visual look-alikes).
123fn 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
142/// Whether a character is legal in an LDH (letter-digit-hyphen) DNS label.
143fn is_ldh(c: char) -> bool {
144    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
145}
146
147/// Whether a permuted label is valid in DNS: 1–63 octets, no leading or
148/// trailing hyphen, and `--` at positions 3–4 only in a well-formed IDNA
149/// A-label (RFC 5891 §4.2.3.1). Permuting an `xn--` label, or inserting a
150/// hyphen, otherwise yields names no registry accepts (`nx--…`, undecodable
151/// punycode).
152fn 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    // A valid A-label decodes, and re-encoding its U-label gives the same
163    // A-label back (rejecting non-canonical and all-ASCII encodings).
164    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
170/// Records `candidate` under `technique` unless it is the original name or
171/// was already produced (by this or an earlier technique).
172fn 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
187/// Splits `cap` across buckets of the given `sizes` by water-filling: every
188/// bucket is offered an equal share of what is left, and a bucket smaller
189/// than its share hands the remainder on to the larger ones. Only buckets
190/// larger than an equal split are truncated, so every non-empty technique
191/// keeps at least `cap / buckets` candidates (or all of them).
192fn 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
205/// Generates typo/homoglyph look-alike candidates for `domain`.
206///
207/// Squats are registrations, so candidates are built from the registrable
208/// domain found with the Public Suffix List: `mail.example.co.uk` is scanned
209/// as `example.co.uk`, and its brand label (`example`, the one immediately
210/// left of the ICANN public suffix) is permuted with the suffix kept, except
211/// for the dedicated `tld-swap` technique, which swaps the whole suffix
212/// (`example.co.uk` → `example.com`). Subdomain labels are dropped: a
213/// `mail.xample.com` candidate names a host the squatter never has to
214/// create, so its DNS probe reads as unregistered and the real squat
215/// `xample.com` is missed. Every candidate is a valid DNS name (labels of at
216/// most 63 octets, `--` at positions 3–4 only in a well-formed `xn--`
217/// A-label). Output is deduplicated, excludes the registrable domain itself,
218/// and capped at 600 candidates with the budget shared fairly across
219/// techniques: each keeps an equal share (or all of its candidates, if it has
220/// fewer), and a small technique's unused share passes to the larger ones. A
221/// bare public suffix has no brand label and yields nothing.
222pub 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    // The registrable domain is one label plus the (possibly multi-label)
230    // public suffix.
231    let Some((label, tld)) = registrable.split_once('.') else {
232        return Vec::new();
233    };
234
235    // Rebuilds the candidate name around a permuted label, rejecting labels
236    // (and names, RFC 1035's 253 octets) that are not valid in DNS.
237    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    // One bucket per technique, in generation order (which also decides
244    // attribution when two techniques produce the same name).
245    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    // Omission: drop each character.
260    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    // Transposition: swap adjacent characters.
272    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    // Repetition: double each character.
284    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    // Adjacent-key replacement.
296    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    // Insertion: insert each lowercase letter at every gap.
307    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    // Bitsquatting: flip each bit of each byte; keep valid LDH results. The
318    // label is an A-label (normalized), so every char is ASCII.
319    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    // Homoglyph substitution.
333    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    // TLD swap: keep the label, swap the whole public suffix.
350    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    // Share the cap across techniques. Label permutations grow with label
360    // length (insertion alone is 26×(L+1)), and a plain tail truncation
361    // silently dropped whole techniques — every tld-swap for ~16+ char
362    // labels, and bitsquat + homoglyph (the namesake technique) for
363    // `paypalsecurelogin.com`. Water-filling only trims the techniques that
364    // exceed an equal split, in practice insertion.
365    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
377/// Whether a candidate survives the DNS presence pre-filter and warrants a
378/// full registry lookup. `Absent` (NXDOMAIN) is treated as unregistered and
379/// dropped; `Unknown` (a failed probe) is not evidence, so it is kept — this
380/// mirrors the smart-lookup thin-fallback ladder's handling of a failed probe.
381fn survives_prefilter(presence: DnsPresence) -> bool {
382    !matches!(presence, DnsPresence::Absent)
383}
384
385/// Turns a candidate's lookup into a registered look-alike, or `None` when
386/// the lookup says the name appears available.
387///
388/// Only an availability claim drops a candidate. `LookupResult::Available`
389/// (and so `DomainInfoSource::Available`) also carries *registered* verdicts
390/// — a delegated apex behind a failed registry leg (`dns_present`), a
391/// throttled registry (`inconclusive`) — and dropping those silently hid
392/// registered look-alikes, exactly the ones a brand-protection scan exists
393/// to surface.
394fn 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
413/// Orders registered look-alikes newest registration first, undated entries
414/// last; ties (and undated entries) by domain name for a stable report.
415fn 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
424/// Scores which `candidates` are registered.
425///
426/// Candidates are first pre-filtered by a cheap DNS presence probe: those that
427/// return `NXDOMAIN` are unregistered and dropped without a registry lookup
428/// (see the module docs). The survivors get a full smart lookup and are kept
429/// unless the lookup says the name appears available. Only that claim drops
430/// one: an inconclusive lookup (a throttled registry) or a DNS-only
431/// registration signal keeps it.
432///
433/// Returns the ranked registered look-alikes together with the number of
434/// candidates that passed the pre-filter and received a full lookup — the
435/// accurate `candidates_checked` figure for the report.
436///
437/// The full lookups run up to `concurrency` at a time; the pre-filter probes
438/// run up to 50 at a time, independent of `concurrency`.
439pub 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/// How many pre-filter probes and full lookups one scan runs at a time.
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449struct FanOut {
450    prefilter: usize,
451    lookups: usize,
452}
453
454impl FanOut {
455    /// One scan's budget (`concurrency` lookups, at least 50 probes) split
456    /// evenly across `scans` scans running at once, so a bulk batch of
457    /// confusables scans keeps the total in flight within that budget instead
458    /// of multiplying it (50 rows × 50 lookups).
459    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    // Pre-filter: probe DNS presence and drop NXDOMAIN candidates before any
477    // registry query. A wider fan-out is fine here — DNS is far cheaper than a
478    // full RDAP+WHOIS race.
479    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    // Freshly-registered squats are the most actionable — newest first,
501    // undated entries last.
502    rank_lookalikes(&mut registered);
503    (registered, candidates_checked)
504}
505
506/// Generates look-alike candidates for `domain` and scores which are
507/// registered, returning a ranked [`ConfusableReport`].
508///
509/// A subdomain is scanned at its registrable domain (see
510/// [`generate_candidates`]); the report keeps the name as given.
511pub 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
519/// [`find_confusables`] for one of `scans` scans running concurrently (a
520/// bulk batch): the per-scan fan-out is `concurrency` divided among them.
521pub(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        // Omission of the leading 'e'.
553        assert!(d.contains(&"xample.com"), "omission missing");
554        // Transposition of 'xa' -> 'ax'.
555        assert!(d.contains(&"eaxmple.com"), "transposition missing");
556        // TLD swap.
557        assert!(d.contains(&"example.net"), "tld-swap missing");
558        // Homoglyph e -> 3.
559        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        // `mail.xample.com` was generated before: a host the squatter never
575        // creates, whose NXDOMAIN presence probe dropped the real squat
576        // `xample.com`. Candidates are registrations, so subdomain labels go.
577        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        // The registrable domain itself is never a candidate.
587        assert!(!cands.iter().any(|c| c.domain == "example.com"));
588    }
589
590    #[test]
591    fn candidates_are_valid_dns_names() {
592        // A 63-octet label must not grow past 63 by insertion/repetition.
593        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        // Permuting an A-label must not produce `nx--…`, `x--n…` or
601        // undecodable punycode; every surviving `--` label is a valid A-label.
602        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        // Reserved `--` at positions 3–4 outside the IDNA prefix.
626        assert!(!is_valid_label("nx--mnchen-3ya"));
627        assert!(!is_valid_label("ab--cd"));
628        // `xn--` that does not decode (a control char, truncated punycode),
629        // does not round-trip, or is malformed.
630        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        // One scan: `concurrency` lookups, at least 50 probes.
640        assert_eq!(
641            FanOut::shared(10, 1),
642            FanOut {
643                prefilter: 50,
644                lookups: 10
645            }
646        );
647        // A bulk batch of 10 concurrent scans keeps the same totals.
648        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        // Never zero (buffer_unordered(0) would stall).
663        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        // A long label produces many insertions; the cap must hold.
675        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        // tld-swaps are generated last; a naive tail truncation dropped every
682        // one of them for ~16+ char labels (insertion alone is 26×(L+1)),
683        // silently disabling one of the most common real squat patterns for
684        // exactly the brand names most worth checking (2026-07-11 review).
685        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, // minus the domain's own TLD
692                "{domain}: every tld-swap candidate must survive the cap"
693            );
694        }
695    }
696
697    #[test]
698    fn variants_are_valid_labels() {
699        // No candidate label may start/end with a hyphen or be empty.
700        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        // NXDOMAIN (Absent) is the unregistered signal → drop it. A delegated
715        // apex (Present) and a failed probe (Unknown) both survive to the full
716        // lookup; a failed probe is not evidence, mirroring the smart-lookup
717        // thin-fallback ladder.
718        assert!(!survives_prefilter(DnsPresence::Absent));
719        assert!(survives_prefilter(DnsPresence::Present));
720        assert!(survives_prefilter(DnsPresence::Unknown));
721    }
722
723    // ---- multi-label public suffixes (PSL) --------------------------------
724
725    #[test]
726    fn multi_label_suffix_permutes_the_brand_not_the_suffix() {
727        // `example.co.uk` used to permute `co` (example.o.uk, example.oc.uk)
728        // and never vary `example`.
729        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        // A subdomain under a multi-label suffix is scanned at its
745        // registrable domain.
746        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    // ---- per-technique budget ----------------------------------------------
759
760    #[test]
761    fn fair_shares_only_trims_buckets_above_an_equal_split() {
762        // Small buckets keep everything; the big one absorbs the cut.
763        assert_eq!(fair_shares(&[5, 500, 10], 100), vec![5, 85, 10]);
764        // Under the cap, nothing is trimmed.
765        assert_eq!(fair_shares(&[3, 4], 100), vec![3, 4]);
766        // Two oversized buckets split what is left evenly.
767        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        // `paypalsecurelogin.com` used to keep 0 of its 13 homoglyphs (and no
774        // bitsquats): they were generated after ~590 insertions and cut first.
775        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        // Only insertion (the bulk technique) is trimmed.
799        let insertions = cands.iter().filter(|c| c.technique == "insertion").count();
800        assert!(insertions < 26 * ("paypalsecurelogin".len() + 1));
801    }
802
803    // ---- ranking -------------------------------------------------------------
804
805    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    // ---- which lookups count as registered -----------------------------
832
833    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        // A delegated apex behind a failed registry leg is registered; it
858        // used to be dropped because its DomainInfo source is `Available`.
859        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        // Only an availability claim drops the candidate.
867        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}