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/// Records `candidate` under `technique` unless it is the original name or
148/// was already produced (by this or an earlier technique).
149fn 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
164/// Splits `cap` across buckets of the given `sizes` by water-filling: every
165/// bucket is offered an equal share of what is left, and a bucket smaller
166/// than its share hands the remainder on to the larger ones. Only buckets
167/// larger than an equal split are truncated, so every non-empty technique
168/// keeps at least `cap / buckets` candidates (or all of them).
169fn 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
182/// Generates typo/homoglyph look-alike candidates for `domain`.
183///
184/// The name is split at its registrable boundary using the Public Suffix
185/// List: the brand label is the one immediately left of the ICANN public
186/// suffix (`example` in `mail.example.co.uk`). That label is permuted; any
187/// deeper subdomain labels and the suffix are preserved, except for the
188/// dedicated `tld-swap` technique, which swaps the whole suffix
189/// (`example.co.uk` → `example.com`). Output is deduplicated, excludes the
190/// input itself, and capped at 600 candidates with the budget shared fairly
191/// across techniques: each keeps an equal share (or all of its candidates, if
192/// it has fewer), and a small technique's unused share passes to the larger
193/// ones. A bare public suffix has no brand label and yields nothing.
194pub 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    // Permute only the registrable label, keeping any deeper subdomain
209    // labels fixed.
210    let (sub, label) = match prefix.rsplit_once('.') {
211        Some((sub, label)) => (Some(sub), label),
212        None => (None, prefix),
213    };
214
215    // Rebuilds the full candidate name around a permuted label, rejecting
216    // labels that are empty or start/end with a hyphen.
217    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    // One bucket per technique, in generation order (which also decides
229    // attribution when two techniques produce the same name).
230    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    // Omission: drop each character.
245    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    // Transposition: swap adjacent characters.
257    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    // Repetition: double each character.
269    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    // Adjacent-key replacement.
281    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    // Insertion: insert each lowercase letter at every gap.
292    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    // Bitsquatting: flip each bit of each byte; keep valid LDH results.
303    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    // Homoglyph substitution.
320    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    // TLD swap: keep the label, swap the whole public suffix.
337    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    // Share the cap across techniques. Label permutations grow with label
350    // length (insertion alone is 26×(L+1)), and a plain tail truncation
351    // silently dropped whole techniques — every tld-swap for ~16+ char
352    // labels, and bitsquat + homoglyph (the namesake technique) for
353    // `paypalsecurelogin.com`. Water-filling only trims the techniques that
354    // exceed an equal split, in practice insertion.
355    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
367/// Whether a candidate survives the DNS presence pre-filter and warrants a
368/// full registry lookup. `Absent` (NXDOMAIN) is treated as unregistered and
369/// dropped; `Unknown` (a failed probe) is not evidence, so it is kept — this
370/// mirrors the smart-lookup thin-fallback ladder's handling of a failed probe.
371fn survives_prefilter(presence: DnsPresence) -> bool {
372    !matches!(presence, DnsPresence::Absent)
373}
374
375/// Turns a candidate's lookup into a registered look-alike, or `None` when
376/// the lookup says the name appears available.
377///
378/// Only an availability claim drops a candidate. `LookupResult::Available`
379/// (and so `DomainInfoSource::Available`) also carries *registered* verdicts
380/// — a delegated apex behind a failed registry leg (`dns_present`), a
381/// throttled registry (`inconclusive`) — and dropping those silently hid
382/// registered look-alikes, exactly the ones a brand-protection scan exists
383/// to surface.
384fn 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
403/// Orders registered look-alikes newest registration first, undated entries
404/// last; ties (and undated entries) by domain name for a stable report.
405fn 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
414/// Scores which `candidates` are registered.
415///
416/// Candidates are first pre-filtered by a cheap DNS presence probe: those that
417/// return `NXDOMAIN` are unregistered and dropped without a registry lookup
418/// (see the module docs). The survivors get a full smart lookup and are kept
419/// unless the lookup says the name appears available. Only that claim drops
420/// one: an inconclusive lookup (a throttled registry) or a DNS-only
421/// registration signal keeps it.
422///
423/// Returns the ranked registered look-alikes together with the number of
424/// candidates that passed the pre-filter and received a full lookup — the
425/// accurate `candidates_checked` figure for the report.
426///
427/// The full lookups run up to `concurrency` at a time; the pre-filter probes
428/// run up to 50 at a time, independent of `concurrency`.
429pub 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    // Pre-filter: probe DNS presence and drop NXDOMAIN candidates before any
439    // registry query. A wider fan-out is fine here — DNS is far cheaper than a
440    // full RDAP+WHOIS race.
441    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    // Freshly-registered squats are the most actionable — newest first,
464    // undated entries last.
465    rank_lookalikes(&mut registered);
466    (registered, candidates_checked)
467}
468
469/// Generates look-alike candidates for `domain` and scores which are
470/// registered, returning a ranked [`ConfusableReport`].
471pub 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        // Omission of the leading 'e'.
501        assert!(d.contains(&"xample.com"), "omission missing");
502        // Transposition of 'xa' -> 'ax'.
503        assert!(d.contains(&"eaxmple.com"), "transposition missing");
504        // TLD swap.
505        assert!(d.contains(&"example.net"), "tld-swap missing");
506        // Homoglyph e -> 3.
507        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        // Every candidate (except tld-swaps) keeps the "mail." subdomain and
524        // ".com" tld; the middle label is what varies.
525        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        // A long label produces many insertions; the cap must hold.
536        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        // tld-swaps are generated last; a naive tail truncation dropped every
543        // one of them for ~16+ char labels (insertion alone is 26×(L+1)),
544        // silently disabling one of the most common real squat patterns for
545        // exactly the brand names most worth checking (2026-07-11 review).
546        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, // minus the domain's own TLD
553                "{domain}: every tld-swap candidate must survive the cap"
554            );
555        }
556    }
557
558    #[test]
559    fn variants_are_valid_labels() {
560        // No candidate label may start/end with a hyphen or be empty.
561        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        // NXDOMAIN (Absent) is the unregistered signal → drop it. A delegated
576        // apex (Present) and a failed probe (Unknown) both survive to the full
577        // lookup; a failed probe is not evidence, mirroring the smart-lookup
578        // thin-fallback ladder.
579        assert!(!survives_prefilter(DnsPresence::Absent));
580        assert!(survives_prefilter(DnsPresence::Present));
581        assert!(survives_prefilter(DnsPresence::Unknown));
582    }
583
584    // ---- multi-label public suffixes (PSL) --------------------------------
585
586    #[test]
587    fn multi_label_suffix_permutes_the_brand_not_the_suffix() {
588        // `example.co.uk` used to permute `co` (example.o.uk, example.oc.uk)
589        // and never vary `example`.
590        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        // Subdomains under a multi-label suffix stay fixed too.
606        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    // ---- per-technique budget ----------------------------------------------
621
622    #[test]
623    fn fair_shares_only_trims_buckets_above_an_equal_split() {
624        // Small buckets keep everything; the big one absorbs the cut.
625        assert_eq!(fair_shares(&[5, 500, 10], 100), vec![5, 85, 10]);
626        // Under the cap, nothing is trimmed.
627        assert_eq!(fair_shares(&[3, 4], 100), vec![3, 4]);
628        // Two oversized buckets split what is left evenly.
629        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        // `paypalsecurelogin.com` used to keep 0 of its 13 homoglyphs (and no
636        // bitsquats): they were generated after ~590 insertions and cut first.
637        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        // Only insertion (the bulk technique) is trimmed.
661        let insertions = cands.iter().filter(|c| c.technique == "insertion").count();
662        assert!(insertions < 26 * ("paypalsecurelogin".len() + 1));
663    }
664
665    // ---- ranking -------------------------------------------------------------
666
667    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    // ---- which lookups count as registered -----------------------------
694
695    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        // A delegated apex behind a failed registry leg is registered; it
720        // used to be dropped because its DomainInfo source is `Available`.
721        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        // Only an availability claim drops the candidate.
729        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}