Skip to main content

squeeze/
domain.rs

1//! Domain name finder.
2//!
3//! Extracts standalone DNS-style domain names (e.g. `example.com`,
4//! `sub.example.co.uk`) whose TLD exists. Anything preceded by `@` or `://`
5//! is skipped to avoid eating the host part of emails and URLs.
6//!
7//! The scanner triggers the finder at every `.` between an alphanumeric
8//! byte and a letter; the finder walks back to the start of the token and
9//! evaluates it exactly as [`Finder::find`] does, remembering a failed
10//! token so its other dots cost nothing.
11
12use super::{Finder, Memo};
13use std::ops::Range;
14
15#[inline]
16pub(crate) fn is_label_byte(b: u8) -> bool {
17    b.is_ascii_alphanumeric() || b == b'-'
18}
19
20#[inline]
21pub(crate) fn looks_like_tld(s: &[u8]) -> bool {
22    (2..=24).contains(&s.len()) && s.iter().all(|b| b.is_ascii_alphabetic())
23}
24
25/// A TLD-shaped label that is delegated in the root zone or reserved for
26/// special use (RFC 6761, RFC 7686, RFC 9476, ICANN's `.internal`), so
27/// `printer.local` matches while `out.write` does not.
28fn is_known_tld(s: &[u8]) -> bool {
29    if !looks_like_tld(s) {
30        return false;
31    }
32    if crate::registry::tlds().contains(s) {
33        return true;
34    }
35    let mut buf = [0u8; 24];
36    let lower = &mut buf[..s.len()];
37    lower.copy_from_slice(s);
38    lower.make_ascii_lowercase();
39    let Ok(tld) = std::str::from_utf8(lower) else {
40        return false;
41    };
42    matches!(
43        tld,
44        "alt" | "example" | "internal" | "invalid" | "local" | "localhost" | "onion" | "test"
45    )
46}
47
48/// True when the byte immediately before `pos` ends a two-byte UTF-8
49/// sequence (Latin-1 Supplement through Greek/Cyrillic and friends).
50///
51/// An identifier candidate glued to such a character is a truncation of a
52/// word (`bücher.de` must not yield `cher.de`), so callers reject it.
53/// Three- and four-byte sequences (CJK, emoji) are kept as legitimate
54/// delimiters, matching the pinned behavior in tests/multibyte.rs.
55#[inline]
56pub(crate) fn glued_to_two_byte_char(input: &[u8], pos: usize) -> bool {
57    pos >= 2 && input[pos - 1] & 0xC0 == 0x80 && input[pos - 2] & 0xE0 == 0xC0
58}
59
60/// Bytes that may make up an email local part, used to detect domain
61/// candidates that are really the local part of an email address.
62#[inline]
63fn is_email_local_byte(b: u8) -> bool {
64    b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'%' | b'+' | b'-')
65}
66
67/// Whether `prev`, the byte before a token, makes the token part of a
68/// larger one (an email local part, a URL path, a scheme).
69#[inline]
70fn continues_token(prev: u8) -> bool {
71    prev == b'@'
72        || prev == b'/'
73        || prev == b':'
74        || prev.is_ascii_alphanumeric()
75        || prev == b'.'
76        || prev == b'-'
77        || prev == b'_'
78}
79
80/// Outcome of evaluating the token that starts at a candidate position.
81enum Eval {
82    /// A domain ends at this position.
83    Match(usize),
84    /// No domain; `find` resumes its search at this position.
85    Skip(usize),
86}
87
88#[derive(Default)]
89pub struct Domain {}
90
91impl Domain {
92    /// Evaluates the token starting at `start`, an alphanumeric byte that
93    /// does not continue a previous token.
94    fn evaluate(input: &[u8], start: usize) -> Eval {
95        // Walk through labels separated by '.'.
96        let mut end = start;
97        let mut label_start = start;
98        let mut dot_count = 0u32;
99        let mut last_dot = 0usize;
100        let mut valid = true;
101
102        while end < input.len() {
103            let b = input[end];
104            if is_label_byte(b) {
105                end += 1;
106            } else if b == b'.' {
107                // A dot not followed by a label byte (ellipsis, end of
108                // sentence or token) ends the domain here instead of
109                // invalidating the candidate.
110                if end + 1 >= input.len() || !is_label_byte(input[end + 1]) {
111                    break;
112                }
113                let label_len = end - label_start;
114                if label_len == 0
115                    || label_len > 63
116                    || input[label_start] == b'-'
117                    || input[end - 1] == b'-'
118                {
119                    valid = false;
120                    break;
121                }
122                dot_count += 1;
123                last_dot = end;
124                label_start = end + 1;
125                end += 1;
126            } else {
127                break;
128            }
129        }
130
131        if !valid || dot_count == 0 || end <= last_dot + 1 {
132            return Eval::Skip(if end > start { end + 1 } else { start + 1 });
133        }
134
135        // Disallow trailing characters that would make this part of a
136        // path/URL or of code (`e.to_string()`, `source.map(f)`).
137        if end < input.len() {
138            let next = input[end];
139            if matches!(next, b'/' | b':' | b'_' | b'(') {
140                return Eval::Skip(end + 1);
141            }
142            // If the token continues as an email local part that ends at
143            // '@', this candidate sits inside an email address (e.g. the
144            // `first.last` of `first.last+tag@company.co.uk`): suppress
145            // it and skip past the token.
146            let mut probe = end;
147            while probe < input.len() && is_email_local_byte(input[probe]) {
148                probe += 1;
149            }
150            if probe < input.len() && input[probe] == b'@' {
151                return Eval::Skip(probe + 1);
152            }
153        }
154
155        // Validate final label as TLD.
156        let tld = &input[last_dot + 1..end];
157        if !is_known_tld(tld) {
158            return Eval::Skip(end);
159        }
160
161        // Reject all-numeric labels (avoid eating IPs).
162        if input[start..end]
163            .iter()
164            .all(|b| b.is_ascii_digit() || *b == b'.')
165        {
166            return Eval::Skip(end);
167        }
168
169        Eval::Match(end)
170    }
171
172    /// The domain, if any, whose last dot (the one before the TLD) or an
173    /// inner dot is at `pos`: walks back over the token the dot belongs to
174    /// and evaluates it from its start, exactly as [`Finder::find`] would
175    /// have when reaching that start.
176    fn from_dot(input: &[u8], pos: usize, memo: &mut Memo) -> Option<Range<usize>> {
177        if input[pos] != b'.' {
178            return None;
179        }
180        // The label before the dot ends with an alphanumeric byte and the
181        // TLD (or the next label) starts with one; a dot elsewhere cannot be
182        // the last dot of a domain.
183        if pos == 0
184            || !input[pos - 1].is_ascii_alphanumeric()
185            || pos + 1 >= input.len()
186            || !input[pos + 1].is_ascii_alphabetic()
187        {
188            return None;
189        }
190        if memo.covers(pos) {
191            return None;
192        }
193        // Every dot of a token walks back to the same start, so a token
194        // that fails once fails for all its dots.
195        let mut start = pos;
196        while start > 0 && (is_label_byte(input[start - 1]) || input[start - 1] == b'.') {
197            start -= 1;
198        }
199        let mut run_end = pos + 1;
200        while run_end < input.len() && (is_label_byte(input[run_end]) || input[run_end] == b'.') {
201            run_end += 1;
202        }
203        let ok = input[start].is_ascii_alphanumeric()
204            && (start == 0
205                || (!continues_token(input[start - 1]) && !glued_to_two_byte_char(input, start)));
206        if ok && let Eval::Match(end) = Self::evaluate(input, start) {
207            return Some(start..end);
208        }
209        memo.start = start;
210        memo.end = run_end;
211        None
212    }
213}
214
215impl Finder for Domain {
216    fn id(&self) -> &'static str {
217        "domain"
218    }
219
220    fn triggerable(&self) -> bool {
221        true
222    }
223
224    fn line_agnostic(&self) -> bool {
225        // Tokens consist of label bytes and dots; every walk stops at a
226        // line terminator like at the end of the input.
227        true
228    }
229
230    fn could_trigger_at(&self, byte: u8) -> bool {
231        byte == b'.'
232    }
233
234    fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
235        prev.is_ascii_alphanumeric()
236    }
237
238    fn could_continue_with(&self, _cur: u8, next: u8) -> bool {
239        next.is_ascii_alphabetic()
240    }
241
242    fn try_trigger_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
243        Self::from_dot(input, pos, &mut Memo::default())
244    }
245
246    fn try_trigger_at_memo(
247        &self,
248        input: &[u8],
249        pos: usize,
250        memo: &mut Memo,
251    ) -> Option<Range<usize>> {
252        Self::from_dot(input, pos, memo)
253    }
254
255    fn find(&self, s: &str) -> Option<Range<usize>> {
256        let input = s.as_bytes();
257        let mut i = 0;
258
259        while i < input.len() {
260            // Find the start of a candidate label.
261            if !input[i].is_ascii_alphanumeric() {
262                i += 1;
263                continue;
264            }
265
266            // Skip if the previous byte makes this part of a larger token
267            // (e.g. email local part, url path, scheme).
268            if i > 0 && (continues_token(input[i - 1]) || glued_to_two_byte_char(input, i)) {
269                i += 1;
270                continue;
271            }
272
273            match Self::evaluate(input, i) {
274                Eval::Match(end) => return Some(i..end),
275                Eval::Skip(next) => i = next,
276            }
277        }
278
279        None
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn id_should_return_domain() {
289        assert_eq!("domain", Domain::default().id());
290    }
291
292    #[test]
293    fn find_simple_domain() {
294        let f = Domain::default();
295        let input = "visit example.com today";
296        let r = f.find(input).unwrap();
297        assert_eq!("example.com", &input[r]);
298    }
299
300    #[test]
301    fn find_subdomain() {
302        let f = Domain::default();
303        let input = "see mail.example.co.uk for details";
304        let r = f.find(input).unwrap();
305        assert_eq!("mail.example.co.uk", &input[r]);
306    }
307
308    #[test]
309    fn find_skips_email() {
310        let f = Domain::default();
311        assert!(f.find("user@example.com").is_none());
312    }
313
314    #[test]
315    fn find_skips_inside_url() {
316        let f = Domain::default();
317        assert!(f.find("https://example.com").is_none());
318    }
319
320    #[test]
321    fn find_rejects_no_dot() {
322        let f = Domain::default();
323        assert!(f.find("localhost").is_none());
324    }
325
326    #[test]
327    fn find_rejects_ip() {
328        let f = Domain::default();
329        assert!(f.find("192.168.1.1").is_none());
330    }
331
332    #[test]
333    fn find_strips_trailing_dot() {
334        let f = Domain::default();
335        let input = "example.com.";
336        let r = f.find(input).unwrap();
337        assert_eq!("example.com", &input[r]);
338    }
339
340    #[test]
341    fn find_hyphenated_domain() {
342        let f = Domain::default();
343        let input = "go to my-site.example.com okay";
344        let r = f.find(input).unwrap();
345        assert_eq!("my-site.example.com", &input[r]);
346    }
347
348    #[test]
349    fn find_rejects_label_starting_with_hyphen() {
350        let f = Domain::default();
351        assert!(f.find("-bad.example.com").is_none());
352    }
353
354    #[test]
355    fn find_handles_empty() {
356        assert!(Domain::default().find("").is_none());
357    }
358
359    // --- Positions ---
360
361    #[test]
362    fn find_domain_at_start() {
363        let f = Domain::default();
364        let input = "example.com is great";
365        let r = f.find(input).unwrap();
366        assert_eq!("example.com", &input[r]);
367    }
368
369    #[test]
370    fn find_domain_at_end() {
371        let f = Domain::default();
372        let input = "see example.com";
373        let r = f.find(input).unwrap();
374        assert_eq!("example.com", &input[r]);
375    }
376
377    #[test]
378    fn find_domain_inside_parens() {
379        let f = Domain::default();
380        let input = "(example.com)";
381        let r = f.find(input).unwrap();
382        assert_eq!("example.com", &input[r]);
383    }
384
385    #[test]
386    fn find_domain_in_markdown_link() {
387        let f = Domain::default();
388        let input = "[link](example.com)";
389        let r = f.find(input).unwrap();
390        assert_eq!("example.com", &input[r]);
391    }
392
393    #[test]
394    fn find_domain_followed_by_comma() {
395        let f = Domain::default();
396        let input = "example.com, foo";
397        let r = f.find(input).unwrap();
398        assert_eq!("example.com", &input[r]);
399    }
400
401    #[test]
402    fn find_domain_followed_by_period_end_of_sentence() {
403        let f = Domain::default();
404        let input = "go to example.com.";
405        let r = f.find(input).unwrap();
406        assert_eq!("example.com", &input[r]);
407    }
408
409    #[test]
410    fn find_domain_followed_by_question_mark() {
411        let f = Domain::default();
412        let input = "is it example.com?";
413        let r = f.find(input).unwrap();
414        assert_eq!("example.com", &input[r]);
415    }
416
417    // --- Case sensitivity ---
418
419    #[test]
420    fn find_uppercase_domain() {
421        let f = Domain::default();
422        let input = "go to EXAMPLE.COM today";
423        let r = f.find(input).unwrap();
424        assert_eq!("EXAMPLE.COM", &input[r]);
425    }
426
427    #[test]
428    fn find_mixed_case_domain() {
429        let f = Domain::default();
430        let input = "see Example.Com";
431        let r = f.find(input).unwrap();
432        assert_eq!("Example.Com", &input[r]);
433    }
434
435    // --- Labels ---
436
437    #[test]
438    fn find_long_subdomain_chain() {
439        let f = Domain::default();
440        let input = "a.b.c.d.e.example.com";
441        let r = f.find(input).unwrap();
442        assert_eq!("a.b.c.d.e.example.com", &input[r]);
443    }
444
445    #[test]
446    fn find_domain_with_numeric_subdomain() {
447        let f = Domain::default();
448        let input = "v2.example.com";
449        let r = f.find(input).unwrap();
450        assert_eq!("v2.example.com", &input[r]);
451    }
452
453    #[test]
454    fn find_rejects_label_ending_with_hyphen() {
455        let f = Domain::default();
456        assert!(f.find("bad-.example.com").is_none());
457    }
458
459    #[test]
460    fn find_rejects_consecutive_dots() {
461        let f = Domain::default();
462        assert!(f.find("foo..bar.com").is_none());
463    }
464
465    #[test]
466    fn find_rejects_tld_too_short() {
467        let f = Domain::default();
468        assert!(f.find("foo.x").is_none());
469    }
470
471    #[test]
472    fn find_rejects_numeric_tld() {
473        let f = Domain::default();
474        assert!(f.find("foo.123").is_none());
475    }
476
477    // --- Multiple ---
478
479    #[test]
480    fn find_multiple_domains_iteratively() {
481        let f = Domain::default();
482        let input = "example.com and other.org";
483        let mut results = Vec::new();
484        let mut idx = 0;
485        while idx < input.len() {
486            if let Some(r) = f.find(&input[idx..]) {
487                results.push(&input[idx + r.start..idx + r.end]);
488                idx += r.end;
489            } else {
490                break;
491            }
492        }
493        assert_eq!(vec!["example.com", "other.org"], results);
494    }
495
496    // --- Avoid eating other tokens ---
497
498    #[test]
499    fn find_skips_inside_path() {
500        let f = Domain::default();
501        // Looks like a domain but is inside a path; we want to skip.
502        assert!(f.find("/usr/example.com/files").is_none());
503    }
504
505    #[test]
506    fn find_skips_ipv6_like() {
507        let f = Domain::default();
508        assert!(f.find("2001:db8::1").is_none());
509    }
510
511    #[test]
512    fn find_skips_when_followed_by_at() {
513        // domain immediately followed by '@' might be confusable with email tail
514        let f = Domain::default();
515        assert!(f.find("foo.com@bar").is_none());
516    }
517
518    #[test]
519    fn find_skips_when_followed_by_path() {
520        let f = Domain::default();
521        // domain.com/ would be the start of a URL — skip
522        assert!(f.find("example.com/path").is_none());
523    }
524
525    #[test]
526    fn find_skips_when_followed_by_port() {
527        let f = Domain::default();
528        // domain:8080 is more URL-like
529        assert!(f.find("example.com:8080").is_none());
530    }
531
532    // --- TLDs ---
533
534    #[test]
535    fn find_long_alpha_tld() {
536        let f = Domain::default();
537        let input = "example.museum";
538        let r = f.find(input).unwrap();
539        assert_eq!("example.museum", &input[r]);
540    }
541
542    #[test]
543    fn find_two_letter_country_tld() {
544        let f = Domain::default();
545        let input = "see example.uk";
546        let r = f.find(input).unwrap();
547        assert_eq!("example.uk", &input[r]);
548    }
549
550    // --- Boundary characters ---
551
552    #[test]
553    fn find_matches_full_token_not_inner() {
554        let f = Domain::default();
555        // "abcdexample.com" is itself a valid domain, so the full token matches.
556        // The inner "example.com" must not be reported separately at offset 4.
557        let input = "abcdexample.com";
558        let r = f.find(input).unwrap();
559        assert_eq!(0..input.len(), r);
560    }
561
562    #[test]
563    fn find_skips_glued_to_underscore_prefix() {
564        let f = Domain::default();
565        assert!(f.find("_example.com").is_none());
566    }
567
568    #[test]
569    fn find_skips_in_dotted_chain_preceded_by_dot() {
570        let f = Domain::default();
571        // prev '.' should disqualify start (avoid mid-chain re-matches)
572        assert!(f.find(".example.com").is_none());
573    }
574}