Skip to main content

whois_rust/
who_is.rs

1#[cfg(feature = "srv")]
2use std::str::FromStr;
3use std::{
4    borrow::Cow,
5    collections::HashMap,
6    fs::File,
7    io::{self, Read, Write},
8    net::{SocketAddr, TcpStream, ToSocketAddrs},
9    path::Path,
10    sync::LazyLock,
11    time::{Duration, Instant},
12};
13
14#[cfg(feature = "srv")]
15use hickory_client::{
16    client::{Client, SyncClient},
17    op::DnsResponse,
18    rr::{DNSClass, Name, RData, Record, RecordType},
19    udp::UdpClientConnection,
20};
21use regex::bytes::Regex;
22use serde_json::{Map, Value};
23#[cfg(feature = "tokio")]
24use tokio::io::{AsyncReadExt, AsyncWriteExt};
25use validators::models::Host;
26
27use crate::{WhoIsError, WhoIsHost, WhoIsLookupOptions, WhoIsServerValue};
28
29const DEFAULT_WHOIS_HOST_PORT: u16 = 43;
30const DEFAULT_WHOIS_HOST_QUERY: &str = "$addr\r\n";
31const READ_BUFFER_SIZE: usize = 8 * 1024;
32
33// The pattern is matched against the raw response, so Unicode mode is turned off to let `\S` mean "any non-whitespace byte". A field name has to start its own line, otherwise a mention of it in the legal notice of a response would be picked up.
34static RE_SERVER: LazyLock<Regex> = LazyLock::new(|| {
35    Regex::new(r"(?m-u)^[^\S\n]*(?:Registrar[^\S\n]+)?(?:ReferralServer|Registrar Whois|Whois Server|WHOIS Server|Registrar WHOIS Server):[^\S\n]*(?:r?whois://)?(\S*)").unwrap()
36});
37
38/// Extract the referral WHOIS host from a query result. The response is not decoded first because a host is always ASCII, and only the first token of the value is taken because responses usually end lines with CRLF and some servers append a note to the host.
39fn extract_referral_host(query_result: &[u8]) -> Option<&str> {
40    let host = RE_SERVER.captures(query_result)?.get(1)?.as_bytes();
41    let host = std::str::from_utf8(host).ok()?;
42
43    if host.is_empty() { None } else { Some(host) }
44}
45
46/// Decode the raw bytes of a WHOIS response into a `String`. Valid UTF-8 always passes through without being copied. When the `charset` feature is disabled, invalid UTF-8 falls back to a lossy conversion (malformed bytes become `U+FFFD`), and the TLD hint is unused.
47#[cfg(not(feature = "charset"))]
48fn decode_response(bytes: Vec<u8>, _tld: Option<&str>) -> String {
49    match String::from_utf8(bytes) {
50        Ok(s) => s,
51        Err(error) => String::from_utf8_lossy(error.as_bytes()).into_owned(),
52    }
53}
54
55/// Decode the raw bytes of a WHOIS response into a `String`. Valid UTF-8 always passes through without being copied. Otherwise the encoding is detected with `chardetng` and decoded with `encoding_rs` (malformed bytes become `U+FFFD`). The TLD hint biases the detector towards the encodings that are common in that TLD, which matters for CJK ccTLDs.
56#[cfg(feature = "charset")]
57fn decode_response(bytes: Vec<u8>, tld: Option<&str>) -> String {
58    use chardetng::{EncodingDetector, Iso2022JpDetection, Utf8Detection};
59
60    let bytes = match String::from_utf8(bytes) {
61        Ok(s) => return s,
62        Err(error) => error.into_bytes(),
63    };
64
65    let mut detector = EncodingDetector::new(Iso2022JpDetection::Allow);
66
67    detector.feed(&bytes, true);
68
69    detector.guess(tld.map(str::as_bytes), Utf8Detection::Allow).decode(&bytes).0.into_owned()
70}
71
72/// Get the TLD of a target to hint the charset detector with. `chardetng` requires it to be ASCII without upper case letters, so an upper case TLD is lower cased and a non-ASCII one gives `None`.
73fn tld_hint(host: &Host) -> Option<Cow<'_, str>> {
74    let Host::Domain(domain) = host else {
75        return None;
76    };
77
78    let tld = match domain.rfind('.') {
79        Some(index) => &domain[index + 1..],
80        None => domain.as_str(),
81    };
82
83    if tld.is_empty() || !tld.is_ascii() {
84        return None;
85    }
86
87    if tld.bytes().any(|b| b.is_ascii_uppercase()) {
88        Some(Cow::Owned(tld.to_ascii_lowercase()))
89    } else {
90        Some(Cow::Borrowed(tld))
91    }
92}
93
94/// Build the text of a target to send to a server. Unlike a URI authority, IPv6 addresses are **not** wrapped in `[` and `]`, which is what WHOIS servers expect.
95fn query_text<'a>(server: &WhoIsServerValue, host: &'a Host) -> Cow<'a, str> {
96    match host {
97        // The domain is already ASCII (Punycode) encoded; decode it back to Unicode for servers that require it.
98        Host::Domain(domain) => server.encode_domain(domain),
99        Host::IPv4(ip) => Cow::Owned(ip.to_string()),
100        Host::IPv6(ip) => Cow::Owned(ip.to_string()),
101    }
102}
103
104/// Build the request to send to a server. RFC 3912 requires a request to be terminated with CRLF, so a query which is not is fixed up instead of being sent as it is.
105fn build_request(server: &WhoIsServerValue, text: &str) -> String {
106    let query = server.query.as_deref().unwrap_or(DEFAULT_WHOIS_HOST_QUERY);
107
108    let mut request = query.replace("$addr", text);
109
110    if !request.ends_with("\r\n") {
111        if request.ends_with('\n') {
112            request.pop();
113        }
114
115        request.push_str("\r\n");
116    }
117
118    request
119}
120
121/// Whether two hosts point at the same WHOIS server. Domains are compared case insensitively, because a referral does not have to spell its host the same way as the server list does.
122fn same_host(a: &Host, b: &Host) -> bool {
123    match (a, b) {
124        (Host::Domain(a), Host::Domain(b)) => a.eq_ignore_ascii_case(b),
125        _ => a == b,
126    }
127}
128
129/// A timeout budget shared by every step of one connection, so that a host with several addresses cannot make a lookup take the timeout once per address.
130#[derive(Debug)]
131struct Deadline {
132    start:   Instant,
133    timeout: Duration,
134}
135
136impl Deadline {
137    #[inline]
138    fn new(timeout: Duration) -> Self {
139        Deadline {
140            start: Instant::now(),
141            timeout,
142        }
143    }
144
145    /// Get the part of the budget which is still available, or an error if it has run out.
146    fn remaining(&self) -> Result<Duration, io::Error> {
147        match self.timeout.checked_sub(self.start.elapsed()) {
148            Some(remaining) if !remaining.is_zero() => Ok(remaining),
149            _ => Err(io::Error::new(io::ErrorKind::TimedOut, "the WHOIS lookup timed out")),
150        }
151    }
152}
153
154/// The error to report when a host has no address left to try.
155#[inline]
156fn no_address_error() -> io::Error {
157    io::Error::new(
158        io::ErrorKind::AddrNotAvailable,
159        "the host is not resolved to any socket address",
160    )
161}
162
163/// Connect to the first address which accepts the connection, spending one shared budget on all of the attempts.
164fn connect(socket_addrs: &[SocketAddr], deadline: &Deadline) -> io::Result<TcpStream> {
165    let mut last_error = None;
166
167    for socket_addr in socket_addrs {
168        match TcpStream::connect_timeout(socket_addr, deadline.remaining()?) {
169            Ok(client) => return Ok(client),
170            Err(error) => last_error = Some(error),
171        }
172    }
173
174    Err(last_error.unwrap_or_else(no_address_error))
175}
176
177/// Read a whole WHOIS response, failing instead of truncating when it is bigger than the given limit. The deadline, when there is one, bounds the whole response instead of each read, so that a server which trickles its response out cannot hold the connection for longer than the timeout.
178fn read_response(
179    client: &mut TcpStream,
180    max_response_size: Option<usize>,
181    deadline: Option<&Deadline>,
182) -> io::Result<Vec<u8>> {
183    let mut query_result = Vec::new();
184    let mut buffer = [0u8; READ_BUFFER_SIZE];
185
186    loop {
187        if let Some(deadline) = deadline {
188            client.set_read_timeout(Some(deadline.remaining()?))?;
189        }
190
191        // RFC 3912: the response is over when the server closes the connection.
192        let read = client.read(&mut buffer)?;
193
194        if read == 0 {
195            break;
196        }
197
198        if let Some(max) = max_response_size
199            && query_result.len() + read > max
200        {
201            return Err(io::Error::new(
202                io::ErrorKind::InvalidData,
203                "the WHOIS response is too big",
204            ));
205        }
206
207        query_result.extend_from_slice(&buffer[..read]);
208    }
209
210    Ok(query_result)
211}
212
213/// Connect to the first address which accepts the connection, spending one shared budget on all of the attempts.
214#[cfg(feature = "tokio")]
215async fn connect_async(
216    socket_addrs: &[SocketAddr],
217    deadline: &Deadline,
218) -> Result<tokio::net::TcpStream, WhoIsError> {
219    let mut last_error = None;
220
221    for socket_addr in socket_addrs {
222        match tokio::time::timeout(
223            deadline.remaining()?,
224            tokio::net::TcpStream::connect(socket_addr),
225        )
226        .await
227        {
228            Ok(Ok(client)) => return Ok(client),
229            Ok(Err(error)) => last_error = Some(WhoIsError::IOError(error)),
230            Err(error) => last_error = Some(WhoIsError::Elapsed(error)),
231        }
232    }
233
234    Err(last_error.unwrap_or_else(|| WhoIsError::IOError(no_address_error())))
235}
236
237/// Read a whole WHOIS response, failing instead of truncating when it is bigger than the given limit. The caller bounds the time this takes by awaiting it with a timeout.
238#[cfg(feature = "tokio")]
239async fn read_response_async(
240    client: &mut tokio::net::TcpStream,
241    max_response_size: Option<usize>,
242) -> io::Result<Vec<u8>> {
243    let mut query_result = Vec::new();
244
245    match max_response_size {
246        Some(max) => {
247            // One byte past the limit is read so that a response which is too big can be told apart from one which exactly fits.
248            client.take(max.saturating_add(1) as u64).read_to_end(&mut query_result).await?;
249
250            if query_result.len() > max {
251                return Err(io::Error::new(
252                    io::ErrorKind::InvalidData,
253                    "the WHOIS response is too big",
254                ));
255            }
256        },
257        None => {
258            client.read_to_end(&mut query_result).await?;
259        },
260    }
261
262    Ok(query_result)
263}
264
265/// The `WhoIs` structure stores the list of WHOIS servers in-memory.
266#[derive(Debug, Clone)]
267pub struct WhoIs {
268    map: HashMap<String, WhoIsServerValue>,
269    ip:  WhoIsServerValue,
270}
271
272impl WhoIs {
273    /// Create a `WhoIs` instance which doesn't have a WHOIS server list. You should provide the host that is used for query ip. You may want to use the host `"whois.arin.net"`.
274    pub fn from_host<T: AsRef<str>>(host: T) -> Result<WhoIs, WhoIsError> {
275        Ok(Self {
276            map: HashMap::new(), ip: WhoIsServerValue::from_string(host)?
277        })
278    }
279
280    /// Read the list of WHOIS servers (JSON data) from a file to create a `WhoIs` instance.
281    #[inline]
282    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<WhoIs, WhoIsError> {
283        let path = path.as_ref();
284
285        let file = File::open(path)?;
286
287        let map: Map<String, Value> = serde_json::from_reader(file)?;
288
289        Self::from_inner(map)
290    }
291
292    /// Read the list of WHOIS servers (JSON data) from a file to create a `WhoIs` instance. For `serde_json` doesn't support async functions, consider just using the `from_path` function.
293    #[cfg(feature = "tokio")]
294    #[inline]
295    pub async fn from_path_async<P: AsRef<Path>>(path: P) -> Result<WhoIs, WhoIsError> {
296        let file = tokio::fs::read(path).await?;
297
298        let map: Map<String, Value> = serde_json::from_slice(file.as_slice())?;
299
300        Self::from_inner(map)
301    }
302
303    /// Read the list of WHOIS servers (JSON data) from a string to create a `WhoIs` instance.
304    #[inline]
305    pub fn from_string<S: AsRef<str>>(string: S) -> Result<WhoIs, WhoIsError> {
306        let string = string.as_ref();
307
308        let map: Map<String, Value> = serde_json::from_str(string)?;
309
310        Self::from_inner(map)
311    }
312
313    fn from_inner(mut map: Map<String, Value>) -> Result<WhoIs, WhoIsError> {
314        let ip = match map.remove("_") {
315            Some(server) => {
316                if let Value::Object(server) = server {
317                    match server.get("ip") {
318                        Some(server) => {
319                            if server.is_null() {
320                                return Err(WhoIsError::MapError(
321                                    "`ip` in the `_` object in the server list is null.",
322                                ));
323                            }
324
325                            WhoIsServerValue::from_value(server)?
326                        },
327                        None => {
328                            return Err(WhoIsError::MapError(
329                                "Cannot find `ip` in the `_` object in the server list.",
330                            ));
331                        },
332                    }
333                } else {
334                    return Err(WhoIsError::MapError("`_` in the server list is not an object."));
335                }
336            },
337            None => return Err(WhoIsError::MapError("Cannot find `_` in the server list.")),
338        };
339
340        let mut new_map: HashMap<String, WhoIsServerValue> = HashMap::with_capacity(map.len());
341
342        for (k, v) in map {
343            if !v.is_null() {
344                let server_value = WhoIsServerValue::from_value(&v)?;
345                new_map.insert(k, server_value);
346            }
347        }
348
349        Ok(WhoIs {
350            map: new_map,
351            ip,
352        })
353    }
354}
355
356#[cfg(feature = "srv")]
357impl WhoIs {
358    /// Try to find a WHOIS server for a TLD by querying the `_nicname._tcp.<tld>` SRV records through the given DNS server, caching any server that is found for later lookups. Returns whether a server is available for the TLD (already known or newly discovered).
359    ///
360    /// One label is stripped off before each query, so pass `".example.com"` to have `example.com` itself queried. A TLD which is already in the server list is never replaced by a discovered one.
361    pub fn can_find_server_for_tld<T: AsRef<str>, D: AsRef<str>>(
362        &mut self,
363        tld: T,
364        dns_server: D,
365    ) -> Result<bool, WhoIsError> {
366        let mut tld = tld.as_ref();
367        let dns_server = dns_server.as_ref();
368
369        // The TLD as given may already be known, in which case nothing else has to be set up.
370        if self.map.contains_key(tld) {
371            return Ok(true);
372        }
373
374        let address = match dns_server.parse() {
375            Ok(address) => address,
376            Err(_error) => {
377                return Err(WhoIsError::MapError("The DNS server address is incorrect."));
378            },
379        };
380        let conn = UdpClientConnection::new(address).map_err(io::Error::other)?;
381        let client = SyncClient::new(conn);
382
383        loop {
384            match tld.find('.') {
385                Some(index) => {
386                    tld = &tld[index + 1..];
387                },
388                None => {
389                    return Ok(false);
390                },
391            }
392
393            if tld.is_empty() {
394                return Ok(false);
395            }
396
397            // The parent is checked before it is queried, so a known server is never queried for nor overwritten.
398            if self.map.contains_key(tld) {
399                return Ok(true);
400            }
401
402            let name =
403                Name::from_str(&format!("_nicname._tcp.{tld}.")).map_err(io::Error::other)?;
404            let response: DnsResponse =
405                client.query(&name, DNSClass::IN, RecordType::SRV).map_err(io::Error::other)?;
406            let answers: &[Record] = response.answers();
407
408            for record in answers {
409                if let Some(RData::SRV(record)) = record.data() {
410                    let target = record.target().to_string();
411                    let new_server =
412                        match WhoIsServerValue::from_string(target.trim_end_matches('.')) {
413                            Ok(new_server) => new_server,
414                            Err(_error) => continue,
415                        };
416
417                    self.map.insert(tld.to_string(), new_server);
418
419                    return Ok(true);
420                }
421            }
422        }
423    }
424}
425
426impl WhoIs {
427    /// Try to find a WHOIS server for a TLD from the in-memory server list, walking up the labels of the given TLD/domain (e.g. `a.b.example` -> `b.example` -> `example` -> `""`) until a match is found. Returns the matched server, if any.
428    pub fn get_server_by_tld(&self, mut tld: &str) -> Option<&WhoIsServerValue> {
429        let mut server;
430
431        loop {
432            server = self.map.get(tld);
433
434            if server.is_some() {
435                break;
436            }
437
438            if tld.is_empty() {
439                break;
440            }
441
442            match tld.find('.') {
443                Some(index) => {
444                    tld = &tld[index + 1..];
445                },
446                None => {
447                    tld = "";
448                },
449            }
450        }
451
452        server
453    }
454
455    /// Find the entry of the server list which is served by a host, so that following a referral to a known server keeps that server's `query` and `punycode` settings.
456    fn get_server_by_host(&self, host: &WhoIsHost) -> Option<&WhoIsServerValue> {
457        self.map.values().find(|server| &server.host == host)
458    }
459
460    /// Pick the server a lookup starts from: the one given in the options, or the best match from the server list.
461    fn get_server<'a>(
462        &'a self,
463        options: &'a WhoIsLookupOptions,
464    ) -> Result<&'a WhoIsServerValue, WhoIsError> {
465        if let Some(server) = &options.server {
466            return Ok(server);
467        }
468
469        match &options.target.0 {
470            Host::IPv4(_) | Host::IPv6(_) => Ok(&self.ip),
471            Host::Domain(domain) => self
472                .get_server_by_tld(domain.as_str())
473                .ok_or(WhoIsError::MapError("No whois server is known for this kind of object.")),
474        }
475    }
476
477    fn lookup_once(
478        server: &WhoIsServerValue,
479        text: &str,
480        options: &WhoIsLookupOptions,
481    ) -> Result<Vec<u8>, WhoIsError> {
482        let addr = server.host.to_addr_string(DEFAULT_WHOIS_HOST_PORT);
483        let request = build_request(server, text);
484
485        match options.timeout {
486            Some(timeout) => {
487                let deadline = Deadline::new(timeout);
488
489                let socket_addrs: Vec<SocketAddr> = addr.to_socket_addrs()?.collect();
490
491                let mut client = connect(&socket_addrs, &deadline)?;
492
493                client.set_write_timeout(Some(deadline.remaining()?))?;
494                client.write_all(request.as_bytes())?;
495                client.flush()?;
496
497                Ok(read_response(&mut client, options.max_response_size, Some(&deadline))?)
498            },
499            None => {
500                let mut client = TcpStream::connect(&addr)?;
501
502                client.write_all(request.as_bytes())?;
503                client.flush()?;
504
505                Ok(read_response(&mut client, options.max_response_size, None)?)
506            },
507        }
508    }
509
510    fn lookup_inner(
511        &self,
512        server: &WhoIsServerValue,
513        options: &WhoIsLookupOptions,
514    ) -> Result<Vec<u8>, WhoIsError> {
515        let host = &options.target.0;
516        let mut follow = options.follow;
517        let mut server = Cow::Borrowed(server);
518
519        let text = query_text(&server, host);
520        let mut query_result = Self::lookup_once(&server, text.as_ref(), options)?;
521
522        while follow > 0 {
523            let Some(referral) = extract_referral_host(&query_result)
524                .and_then(|h| WhoIsServerValue::from_string(h).ok())
525            else {
526                break;
527            };
528
529            // The referral may carry a port, so compare the host alone to stop a server which refers to itself.
530            if same_host(referral.host.host(), server.host.host()) {
531                break;
532            }
533
534            server = match self.get_server_by_host(&referral.host) {
535                Some(server) => Cow::Borrowed(server),
536                None => Cow::Owned(referral),
537            };
538
539            let text = query_text(&server, host);
540
541            query_result = Self::lookup_once(&server, text.as_ref(), options)?;
542
543            follow -= 1;
544        }
545
546        Ok(query_result)
547    }
548
549    /// Lookup a domain or an IP, returning the raw bytes of the final WHOIS response without decoding.
550    #[inline]
551    pub fn lookup_raw(&self, options: WhoIsLookupOptions) -> Result<Vec<u8>, WhoIsError> {
552        let server = self.get_server(&options)?;
553
554        self.lookup_inner(server, &options)
555    }
556
557    /// Lookup a domain or an IP.
558    pub fn lookup(&self, options: WhoIsLookupOptions) -> Result<String, WhoIsError> {
559        let server = self.get_server(&options)?;
560
561        let query_result = self.lookup_inner(server, &options)?;
562
563        Ok(decode_response(query_result, tld_hint(&options.target.0).as_deref()))
564    }
565}
566
567#[cfg(feature = "tokio")]
568impl WhoIs {
569    async fn lookup_once_async(
570        server: &WhoIsServerValue,
571        text: &str,
572        options: &WhoIsLookupOptions,
573    ) -> Result<Vec<u8>, WhoIsError> {
574        let addr = server.host.to_addr_string(DEFAULT_WHOIS_HOST_PORT);
575        let request = build_request(server, text);
576
577        match options.timeout {
578            Some(timeout) => {
579                let deadline = Deadline::new(timeout);
580
581                // Resolving has to go through tokio as well, because the blocking resolver would stall the whole runtime.
582                let socket_addrs: Vec<SocketAddr> =
583                    tokio::time::timeout(deadline.remaining()?, tokio::net::lookup_host(&addr))
584                        .await??
585                        .collect();
586
587                let mut client = connect_async(&socket_addrs, &deadline).await?;
588
589                tokio::time::timeout(deadline.remaining()?, client.write_all(request.as_bytes()))
590                    .await??;
591                tokio::time::timeout(deadline.remaining()?, client.flush()).await??;
592
593                Ok(tokio::time::timeout(
594                    deadline.remaining()?,
595                    read_response_async(&mut client, options.max_response_size),
596                )
597                .await??)
598            },
599            None => {
600                let mut client = tokio::net::TcpStream::connect(&addr).await?;
601
602                client.write_all(request.as_bytes()).await?;
603                client.flush().await?;
604
605                Ok(read_response_async(&mut client, options.max_response_size).await?)
606            },
607        }
608    }
609
610    async fn lookup_inner_async(
611        &self,
612        server: &WhoIsServerValue,
613        options: &WhoIsLookupOptions,
614    ) -> Result<Vec<u8>, WhoIsError> {
615        let host = &options.target.0;
616        let mut follow = options.follow;
617        let mut server = Cow::Borrowed(server);
618
619        let text = query_text(&server, host);
620        let mut query_result = Self::lookup_once_async(&server, text.as_ref(), options).await?;
621
622        while follow > 0 {
623            let Some(referral) = extract_referral_host(&query_result)
624                .and_then(|h| WhoIsServerValue::from_string(h).ok())
625            else {
626                break;
627            };
628
629            // The referral may carry a port, so compare the host alone to stop a server which refers to itself.
630            if same_host(referral.host.host(), server.host.host()) {
631                break;
632            }
633
634            server = match self.get_server_by_host(&referral.host) {
635                Some(server) => Cow::Borrowed(server),
636                None => Cow::Owned(referral),
637            };
638
639            let text = query_text(&server, host);
640
641            query_result = Self::lookup_once_async(&server, text.as_ref(), options).await?;
642
643            follow -= 1;
644        }
645
646        Ok(query_result)
647    }
648
649    /// Lookup a domain or an IP, returning the raw bytes of the final WHOIS response without decoding.
650    #[inline]
651    pub async fn lookup_raw_async(
652        &self,
653        options: WhoIsLookupOptions,
654    ) -> Result<Vec<u8>, WhoIsError> {
655        let server = self.get_server(&options)?;
656
657        self.lookup_inner_async(server, &options).await
658    }
659
660    /// Lookup a domain or an IP.
661    pub async fn lookup_async(&self, options: WhoIsLookupOptions) -> Result<String, WhoIsError> {
662        let server = self.get_server(&options)?;
663
664        let query_result = self.lookup_inner_async(server, &options).await?;
665
666        Ok(decode_response(query_result, tld_hint(&options.target.0).as_deref()))
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use validators::models::Host;
673
674    use super::{build_request, decode_response, extract_referral_host, tld_hint};
675    use crate::WhoIsServerValue;
676
677    #[test]
678    fn extract_referral_host_trims_trailing_cr() {
679        let body = b"Domain: example.com\r\nReferralServer: whois://whois.arin.net\r\n";
680
681        assert_eq!(Some("whois.arin.net"), extract_referral_host(body));
682    }
683
684    #[test]
685    fn extract_referral_host_ignores_non_utf8_body() {
686        // The referral line is ASCII even when the rest of the response is in some other encoding.
687        let body = b"\xB5n\xBFy: example.tw\r\nWhois Server: whois.twnic.net.tw\r\n";
688
689        assert_eq!(Some("whois.twnic.net.tw"), extract_referral_host(body));
690    }
691
692    #[test]
693    fn extract_referral_host_takes_the_first_token() {
694        let body = b"   Registrar WHOIS Server: whois.example.com (see the note below)\r\n";
695
696        assert_eq!(Some("whois.example.com"), extract_referral_host(body));
697    }
698
699    #[test]
700    fn extract_referral_host_ignores_a_field_name_in_the_middle_of_a_line() {
701        let body = b"Do not trust any Whois Server: which is not listed above.\r\n";
702
703        assert_eq!(None, extract_referral_host(body));
704    }
705
706    #[test]
707    fn tld_hint_takes_the_last_label() {
708        assert_eq!(Some("tw"), tld_hint(&Host::Domain(String::from("magiclen.com.tw"))).as_deref());
709        assert_eq!(Some("com"), tld_hint(&Host::Domain(String::from("magiclen.COM"))).as_deref());
710        assert_eq!(None, tld_hint(&Host::IPv4("172.105.210.153".parse().unwrap())).as_deref());
711    }
712
713    #[test]
714    fn build_request_ends_with_crlf() {
715        let mut server = WhoIsServerValue::from_string("whois.example.com").unwrap();
716
717        assert_eq!("magiclen.org\r\n", build_request(&server, "magiclen.org"));
718
719        server.query = Some(String::from("domain $addr\r\n"));
720        assert_eq!("domain magiclen.org\r\n", build_request(&server, "magiclen.org"));
721
722        // A query which is terminated with LF alone, or not terminated at all, is fixed up.
723        server.query = Some(String::from("domain $addr\n"));
724        assert_eq!("domain magiclen.org\r\n", build_request(&server, "magiclen.org"));
725
726        server.query = Some(String::from("domain $addr"));
727        assert_eq!("domain magiclen.org\r\n", build_request(&server, "magiclen.org"));
728    }
729
730    #[test]
731    fn decode_response_passes_through_utf8() {
732        assert_eq!("café", decode_response("café".as_bytes().to_vec(), None));
733    }
734
735    // "café" with `é` as the single byte 0xE9 (Windows-1252 / ISO-8859-1).
736    #[cfg(feature = "charset")]
737    #[test]
738    fn decode_response_detects_windows_1252() {
739        assert_eq!("café", decode_response(vec![b'c', b'a', b'f', 0xE9], None));
740    }
741
742    #[cfg(not(feature = "charset"))]
743    #[test]
744    fn decode_response_is_lossy_without_charset() {
745        assert_eq!("caf\u{FFFD}", decode_response(vec![b'c', b'a', b'f', 0xE9], None));
746    }
747}