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.
44const MAX_CANDIDATES: usize = 600;
45
46/// Concurrency for the cheap DNS presence pre-filter. DNS probes are far
47/// lighter than a full RDAP+WHOIS race, so we fan them out wider than the
48/// (registry-facing) full-lookup concurrency to keep the pre-filter fast.
49const PREFILTER_CONCURRENCY: usize = 50;
50
51/// Common alternate TLDs used for TLD-swap squats.
52const SWAP_TLDS: &[&str] = &[
53    "com", "net", "org", "co", "io", "info", "biz", "app", "dev", "xyz", "online", "site",
54];
55
56/// A generated look-alike candidate and the technique that produced it.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ConfusableCandidate {
59    pub domain: String,
60    /// The permutation technique (e.g. `omission`, `homoglyph`, `tld-swap`).
61    pub technique: String,
62}
63
64/// A registered look-alike surfaced by scoring.
65#[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/// The result of a confusables scan.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ConfusableReport {
80    pub domain: String,
81    pub candidates_generated: usize,
82    pub candidates_checked: usize,
83    /// Registered look-alikes, most-recently-registered first.
84    pub registered: Vec<RegisteredLookalike>,
85}
86
87/// Returns the QWERTY-adjacent keys for `c` (used for fat-finger substitutions).
88fn 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
120/// Returns ASCII-homoglyph replacement strings for `c` (visual look-alikes).
121fn 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
140/// Whether a character is legal in an LDH (letter-digit-hyphen) DNS label.
141fn is_ldh(c: char) -> bool {
142    c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'
143}
144
145/// Records `candidate` under `technique` unless it is the original name or
146/// was already produced (by this or an earlier technique).
147fn 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
162/// Splits `cap` across buckets of the given `sizes` by water-filling: every
163/// bucket is offered an equal share of what is left, and a bucket smaller
164/// than its share hands the remainder on to the larger ones. Only buckets
165/// larger than an equal split are truncated, so every non-empty technique
166/// keeps at least `cap / buckets` candidates (or all of them).
167fn 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
180/// Generates typo/homoglyph look-alike candidates for `domain`.
181///
182/// The name is split at its registrable boundary using the Public Suffix
183/// List: the brand label is the one immediately left of the ICANN public
184/// suffix (`example` in `mail.example.co.uk`). That label is permuted; any
185/// deeper subdomain labels and the suffix are preserved, except for the
186/// dedicated `tld-swap` technique, which swaps the whole suffix
187/// (`example.co.uk` → `example.com`). Output is deduplicated, excludes the
188/// input itself, and capped at [`MAX_CANDIDATES`] with the budget shared
189/// fairly across techniques (see [`fair_shares`]). A bare public suffix has
190/// no brand label and yields nothing.
191pub 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    // Permute only the registrable label, keeping any deeper subdomain
206    // labels fixed.
207    let (sub, label) = match prefix.rsplit_once('.') {
208        Some((sub, label)) => (Some(sub), label),
209        None => (None, prefix),
210    };
211
212    // Rebuilds the full candidate name around a permuted label, rejecting
213    // labels that are empty or start/end with a hyphen.
214    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    // One bucket per technique, in generation order (which also decides
226    // attribution when two techniques produce the same name).
227    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    // Omission: drop each character.
242    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    // Transposition: swap adjacent characters.
254    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    // Repetition: double each character.
266    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    // Adjacent-key replacement.
278    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    // Insertion: insert each lowercase letter at every gap.
289    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    // Bitsquatting: flip each bit of each byte; keep valid LDH results.
300    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    // Homoglyph substitution.
317    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    // TLD swap: keep the label, swap the whole public suffix.
334    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    // Share the cap across techniques. Label permutations grow with label
347    // length (insertion alone is 26×(L+1)), and a plain tail truncation
348    // silently dropped whole techniques — every tld-swap for ~16+ char
349    // labels, and bitsquat + homoglyph (the namesake technique) for
350    // `paypalsecurelogin.com`. Water-filling only trims the techniques that
351    // exceed an equal split, in practice insertion.
352    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
364/// Whether a candidate survives the DNS presence pre-filter and warrants a
365/// full registry lookup. `Absent` (NXDOMAIN) is treated as unregistered and
366/// dropped; `Unknown` (a failed probe) is not evidence, so it is kept — this
367/// mirrors the smart-lookup thin-fallback ladder's handling of a failed probe.
368fn survives_prefilter(presence: DnsPresence) -> bool {
369    !matches!(presence, DnsPresence::Absent)
370}
371
372/// Turns a candidate's lookup into a registered look-alike, or `None` when
373/// the lookup says the name appears available.
374///
375/// Only an availability claim drops a candidate. `LookupResult::Available`
376/// (and so `DomainInfoSource::Available`) also carries *registered* verdicts
377/// — a delegated apex behind a failed registry leg (`dns_present`), a
378/// throttled registry (`inconclusive`) — and dropping those silently hid
379/// registered look-alikes, exactly the ones a brand-protection scan exists
380/// to surface.
381fn 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
400/// Orders registered look-alikes newest registration first, undated entries
401/// last; ties (and undated entries) by domain name for a stable report.
402fn 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
411/// Scores which `candidates` are registered.
412///
413/// Candidates are first pre-filtered by a cheap DNS presence probe: those that
414/// return `NXDOMAIN` are unregistered and dropped without a registry lookup
415/// (see the module docs). The survivors get a full smart lookup and are kept
416/// unless the lookup says the name appears available (see
417/// [`lookalike_from_result`]).
418///
419/// Returns the ranked registered look-alikes together with the number of
420/// candidates that passed the pre-filter and received a full lookup — the
421/// accurate `candidates_checked` figure for the report.
422///
423/// The full lookups run up to `concurrency` at a time; the pre-filter probes
424/// run at [`PREFILTER_CONCURRENCY`].
425pub 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    // Pre-filter: probe DNS presence and drop NXDOMAIN candidates before any
435    // registry query. A wider fan-out is fine here — DNS is far cheaper than a
436    // full RDAP+WHOIS race.
437    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    // Freshly-registered squats are the most actionable — newest first,
460    // undated entries last.
461    rank_lookalikes(&mut registered);
462    (registered, candidates_checked)
463}
464
465/// Generates look-alike candidates for `domain` and scores which are
466/// registered, returning a ranked [`ConfusableReport`].
467pub 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        // Omission of the leading 'e'.
497        assert!(d.contains(&"xample.com"), "omission missing");
498        // Transposition of 'xa' -> 'ax'.
499        assert!(d.contains(&"eaxmple.com"), "transposition missing");
500        // TLD swap.
501        assert!(d.contains(&"example.net"), "tld-swap missing");
502        // Homoglyph e -> 3.
503        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        // Every candidate (except tld-swaps) keeps the "mail." subdomain and
520        // ".com" tld; the middle label is what varies.
521        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        // A long label produces many insertions; the cap must hold.
532        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        // tld-swaps are generated last; a naive tail truncation dropped every
539        // one of them for ~16+ char labels (insertion alone is 26×(L+1)),
540        // silently disabling one of the most common real squat patterns for
541        // exactly the brand names most worth checking (2026-07-11 review).
542        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, // minus the domain's own TLD
549                "{domain}: every tld-swap candidate must survive the cap"
550            );
551        }
552    }
553
554    #[test]
555    fn variants_are_valid_labels() {
556        // No candidate label may start/end with a hyphen or be empty.
557        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        // NXDOMAIN (Absent) is the unregistered signal → drop it. A delegated
572        // apex (Present) and a failed probe (Unknown) both survive to the full
573        // lookup; a failed probe is not evidence, mirroring the smart-lookup
574        // thin-fallback ladder.
575        assert!(!survives_prefilter(DnsPresence::Absent));
576        assert!(survives_prefilter(DnsPresence::Present));
577        assert!(survives_prefilter(DnsPresence::Unknown));
578    }
579
580    // ---- multi-label public suffixes (PSL) --------------------------------
581
582    #[test]
583    fn multi_label_suffix_permutes_the_brand_not_the_suffix() {
584        // `example.co.uk` used to permute `co` (example.o.uk, example.oc.uk)
585        // and never vary `example`.
586        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        // Subdomains under a multi-label suffix stay fixed too.
602        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    // ---- per-technique budget ----------------------------------------------
617
618    #[test]
619    fn fair_shares_only_trims_buckets_above_an_equal_split() {
620        // Small buckets keep everything; the big one absorbs the cut.
621        assert_eq!(fair_shares(&[5, 500, 10], 100), vec![5, 85, 10]);
622        // Under the cap, nothing is trimmed.
623        assert_eq!(fair_shares(&[3, 4], 100), vec![3, 4]);
624        // Two oversized buckets split what is left evenly.
625        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        // `paypalsecurelogin.com` used to keep 0 of its 13 homoglyphs (and no
632        // bitsquats): they were generated after ~590 insertions and cut first.
633        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        // Only insertion (the bulk technique) is trimmed.
657        let insertions = cands.iter().filter(|c| c.technique == "insertion").count();
658        assert!(insertions < 26 * ("paypalsecurelogin".len() + 1));
659    }
660
661    // ---- ranking -------------------------------------------------------------
662
663    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    // ---- which lookups count as registered -----------------------------
690
691    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        // A delegated apex behind a failed registry leg is registered; it
716        // used to be dropped because its DomainInfo source is `Available`.
717        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        // Only an availability claim drops the candidate.
725        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}