diff --git a/.github/workflows/ci-version.yml b/.github/workflows/ci-version.yml index b622a43..f0b31e4 100644 --- a/.github/workflows/ci-version.yml +++ b/.github/workflows/ci-version.yml @@ -26,7 +26,7 @@ jobs: name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} (${{ matrix.features }}) runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: submodules: recursive - uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -44,14 +44,14 @@ jobs: - macos-latest - windows-latest toolchain: - - 1.69 + - 1.89 features: - - --features tokio name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} (${{ matrix.features }}) runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: submodules: recursive - uses: actions-rust-lang/setup-rust-toolchain@v1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 723a04d..5548ecd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ jobs: rustfmt: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: nightly @@ -19,7 +19,7 @@ jobs: clippy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: actions-rust-lang/setup-rust-toolchain@v1 with: components: clippy @@ -42,7 +42,7 @@ jobs: name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} (${{ matrix.features }}) runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: submodules: recursive - uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -60,14 +60,14 @@ jobs: - macos-latest - windows-latest toolchain: - - 1.69 + - 1.89 features: - - --features tokio name: Test ${{ matrix.toolchain }} on ${{ matrix.os }} (${{ matrix.features }}) runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: submodules: recursive - uses: actions-rust-lang/setup-rust-toolchain@v1 diff --git a/.gitignore b/.gitignore index 3957ffb..9d24995 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ -# Created by https://www.gitignore.io/api/intellij+all - ### Intellij+all ### -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff @@ -11,6 +9,12 @@ .idea/**/dictionaries .idea/**/shelf +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + # Sensitive or high-churn files .idea/**/dataSources/ .idea/**/dataSources.ids @@ -28,9 +32,14 @@ # When using Gradle or Maven with auto-import, you should exclude module files, # since they will be recreated, and may cause churn. Uncomment if using # auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml # .idea/modules.xml # .idea/*.iml # .idea/modules +# *.iml +# *.ipr # CMake cmake-build-*/ @@ -53,6 +62,9 @@ atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml +# SonarLint plugin +.idea/sonarlint/ + # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties @@ -62,27 +74,23 @@ fabric.properties # Editor-based Rest Client .idea/httpRequests -### Intellij+all Patch ### -# Ignores the whole .idea folder and all .iml files -# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 - -.idea/ - -# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 - -*.iml -modules.xml -.idea/misc.xml -*.ipr +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser +### Intellij+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. -# End of https://www.gitignore.io/api/intellij+all +.idea/* +!.idea/codeStyles +!.idea/runConfigurations ### Rust ### # Generated by Cargo # will have compiled files and executables -/target/ +debug/ +target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html @@ -91,5 +99,45 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk - -# End of https://www.gitignore.io/api/rust +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide diff --git a/Cargo.toml b/Cargo.toml index db273d1..82c9153 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,32 +1,31 @@ [package] name = "whois-rust" -version = "1.6.0" +version = "2.0.0" authors = ["Magic Len "] -edition = "2021" -rust-version = "1.69" +edition = "2024" +rust-version = "1.89" repository = "https://github.com/magiclen/whois-rust" homepage = "https://magiclen.org/whois-rust" keywords = ["whois"] -categories = ["web-programming::websocket"] +categories = ["network-programming"] description = "This is a WHOIS client library for Rust, inspired by https://github.com/hjr265/node-whois" license = "MIT" include = ["src/**/*", "Cargo.toml", "README.md", "LICENSE"] [dependencies] -serde_json = "1" -once_cell = "1" +serde_json = "1.0.45" regex = "1" -trust-dns-client = "0.23" +hickory-client = "0.24" -tokio = { version = "1", features = ["fs", "net", "time", "io-util"], optional = true } +tokio = { version = "1.21", features = ["fs", "net", "time", "io-util"], optional = true } + +validators = { version = "0.26", default-features = false, features = ["derive", "host"] } [dev-dependencies] -tokio = { version = "1", features = ["macros", "rt"] } +tokio = { version = "1.21", features = ["macros", "rt"] } -[dependencies.validators] -version = "0.25" -default-features = false -features = ["derive", "host"] +[features] +tokio = ["dep:tokio"] [package.metadata.docs.rs] all-features = true diff --git a/node-whois b/node-whois index d159482..c92616c 160000 --- a/node-whois +++ b/node-whois @@ -1 +1 @@ -Subproject commit d159482cd4ed348f21f0e87196dbb9c0515500f7 +Subproject commit c92616c4a3feb9b473c0092c9f120310ce2fc846 diff --git a/src/who_is.rs b/src/who_is.rs index 3769e33..c99de05 100644 --- a/src/who_is.rs +++ b/src/who_is.rs @@ -1,24 +1,24 @@ use std::{ collections::HashMap, fs::File, - io::{Read, Write}, + io::{self, Read, Write}, net::{SocketAddr, TcpStream, ToSocketAddrs}, path::Path, str::FromStr, + sync::LazyLock, time::Duration, }; -use once_cell::sync::Lazy; -use regex::Regex; -use serde_json::{Map, Value}; -#[cfg(feature = "tokio")] -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use trust_dns_client::{ +use hickory_client::{ client::{Client, SyncClient}, op::DnsResponse, rr::{DNSClass, Name, RData, Record, RecordType}, udp::UdpClientConnection, }; +use regex::Regex; +use serde_json::{Map, Value}; +#[cfg(feature = "tokio")] +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use validators::{models::Host, prelude::*}; use crate::{WhoIsError, WhoIsLookupOptions, WhoIsServerValue}; @@ -26,10 +26,17 @@ use crate::{WhoIsError, WhoIsLookupOptions, WhoIsServerValue}; const DEFAULT_WHOIS_HOST_PORT: u16 = 43; const DEFAULT_WHOIS_HOST_QUERY: &str = "$addr\r\n"; -static RE_SERVER: Lazy = Lazy::new(|| { +static RE_SERVER: LazyLock = LazyLock::new(|| { Regex::new(r"(ReferralServer|Registrar Whois|Whois Server|WHOIS Server|Registrar WHOIS Server):[^\S\n]*(r?whois://)?(.*)").unwrap() }); +/// Extract the referral WHOIS host from a query result. The captured value is trimmed because responses usually end lines with CRLF, and a trailing `\r` would make the host unparsable. +fn extract_referral_host(query_result: &str) -> Option<&str> { + let host = RE_SERVER.captures(query_result)?.get(3)?.as_str().trim(); + + if host.is_empty() { None } else { Some(host) } +} + /// The `WhoIs` structure stores the list of WHOIS servers in-memory. #[derive(Debug, Clone)] pub struct WhoIs { @@ -122,21 +129,27 @@ impl WhoIs { } impl WhoIs { + /// Try to find a WHOIS server for a TLD by querying the `_nicname._tcp.` 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). pub fn can_find_server_for_tld, D: AsRef>( &mut self, tld: T, dns_server: D, - ) -> bool { + ) -> Result { let mut tld = tld.as_ref(); let dns_server = dns_server.as_ref(); - let address = dns_server.parse().unwrap(); - let conn = UdpClientConnection::new(address).unwrap(); + let address = match dns_server.parse() { + Ok(address) => address, + Err(_error) => { + return Err(WhoIsError::MapError("The DNS server address is incorrect.")); + }, + }; + let conn = UdpClientConnection::new(address).map_err(io::Error::other)?; let client = SyncClient::new(conn); loop { if self.map.contains_key(tld) { - break; + return Ok(true); } match tld.find('.') { @@ -149,30 +162,30 @@ impl WhoIs { } if tld.is_empty() { - break; + return Ok(false); } - let name = Name::from_str(&format!("_nicname._tcp.{}.", tld)).unwrap(); - let response: DnsResponse = client.query(&name, DNSClass::IN, RecordType::SRV).unwrap(); + let name = + Name::from_str(&format!("_nicname._tcp.{tld}.")).map_err(io::Error::other)?; + let response: DnsResponse = + client.query(&name, DNSClass::IN, RecordType::SRV).map_err(io::Error::other)?; let answers: &[Record] = response.answers(); for record in answers { if let Some(RData::SRV(record)) = record.data() { let target = record.target().to_string(); let new_server = - match WhoIsServerValue::from_string(&target[..target.len() - 1]) { + match WhoIsServerValue::from_string(target.trim_end_matches('.')) { Ok(new_server) => new_server, Err(_error) => continue, }; self.map.insert(tld.to_string(), new_server); - return true; + return Ok(true); } } } - - false } fn get_server_by_tld(&self, mut tld: &str) -> Option<&WhoIsServerValue> { @@ -212,6 +225,14 @@ impl WhoIs { let mut client = if let Some(timeout) = timeout { let socket_addrs: Vec = addr.to_socket_addrs()?.collect(); + if socket_addrs.is_empty() { + return Err(io::Error::new( + io::ErrorKind::AddrNotAvailable, + "the host is not resolved to any socket address", + ) + .into()); + } + let mut client = None; for socket_addr in socket_addrs.iter().take(socket_addrs.len() - 1) { @@ -247,7 +268,8 @@ impl WhoIs { client.read_to_string(&mut query_result)?; - Ok((addr, query_result)) + // Return the host without the port so a referral host can be compared against it. + Ok((server.host.host.to_string(), query_result)) } fn lookup_inner( @@ -259,19 +281,15 @@ impl WhoIs { let mut query_result = Self::lookup_once(server, text, timeout)?; while follow > 0 { - if let Some(c) = RE_SERVER.captures(&query_result.1) { - if let Some(h) = c.get(3) { - let h = h.as_str(); - if h.ne(&query_result.0) { - if let Ok(server) = WhoIsServerValue::from_string(h) { - query_result = Self::lookup_once(&server, text, timeout)?; + if let Some(h) = extract_referral_host(&query_result.1) + && !h.eq_ignore_ascii_case(&query_result.0) + && let Ok(server) = WhoIsServerValue::from_string(h) + { + query_result = Self::lookup_once(&server, text, timeout)?; - follow -= 1; + follow -= 1; - continue; - } - } - } + continue; } break; @@ -309,9 +327,10 @@ impl WhoIs { }, }; - // punycode check is not necessary because the domain has been ascii-encoded + // The domain is already ASCII (Punycode) encoded; decode it back to Unicode for servers that require it. + let text = server.encode_domain(domain); - Self::lookup_inner(server, domain, options.timeout, options.follow) + Self::lookup_inner(server, text.as_ref(), options.timeout, options.follow) }, } } @@ -319,7 +338,7 @@ impl WhoIs { #[cfg(feature = "tokio")] impl WhoIs { - async fn lookup_inner_once_async<'a>( + async fn lookup_once_async( server: &WhoIsServerValue, text: &str, timeout: Option, @@ -329,6 +348,14 @@ impl WhoIs { if let Some(timeout) = timeout { let socket_addrs: Vec = addr.to_socket_addrs()?.collect(); + if socket_addrs.is_empty() { + return Err(io::Error::new( + io::ErrorKind::AddrNotAvailable, + "the host is not resolved to any socket address", + ) + .into()); + } + let mut client = None; for socket_addr in socket_addrs.iter().take(socket_addrs.len() - 1) { @@ -368,7 +395,8 @@ impl WhoIs { tokio::time::timeout(timeout, client.read_to_string(&mut query_result)).await??; - Ok((addr, query_result)) + // Return the host without the port so a referral host can be compared against it. + Ok((server.host.host.to_string(), query_result)) } else { let mut client = tokio::net::TcpStream::connect(&addr).await?; @@ -386,7 +414,8 @@ impl WhoIs { client.read_to_string(&mut query_result).await?; - Ok((addr, query_result)) + // Return the host without the port so a referral host can be compared against it. + Ok((server.host.host.to_string(), query_result)) } } @@ -396,23 +425,18 @@ impl WhoIs { timeout: Option, mut follow: u16, ) -> Result { - let mut query_result = Self::lookup_inner_once_async(server, text, timeout).await?; + let mut query_result = Self::lookup_once_async(server, text, timeout).await?; while follow > 0 { - if let Some(c) = RE_SERVER.captures(&query_result.1) { - if let Some(h) = c.get(3) { - let h = h.as_str(); - if h.ne(&query_result.0) { - if let Ok(server) = WhoIsServerValue::from_string(h) { - query_result = - Self::lookup_inner_once_async(&server, text, timeout).await?; - - follow -= 1; - - continue; - } - } - } + if let Some(h) = extract_referral_host(&query_result.1) + && !h.eq_ignore_ascii_case(&query_result.0) + && let Ok(server) = WhoIsServerValue::from_string(h) + { + query_result = Self::lookup_once_async(&server, text, timeout).await?; + + follow -= 1; + + continue; } break; @@ -451,10 +475,24 @@ impl WhoIs { }, }; - // punycode check is not necessary because the domain has been ascii-encoded + // The domain is already ASCII (Punycode) encoded; decode it back to Unicode for servers that require it. + let text = server.encode_domain(domain); - Self::lookup_inner_async(server, domain, options.timeout, options.follow).await + Self::lookup_inner_async(server, text.as_ref(), options.timeout, options.follow) + .await }, } } } + +#[cfg(test)] +mod tests { + use super::extract_referral_host; + + #[test] + fn extract_referral_host_trims_trailing_cr() { + let body = "Domain: example.com\r\nReferralServer: whois://whois.arin.net\r\n"; + + assert_eq!(Some("whois.arin.net"), extract_referral_host(body)); + } +} diff --git a/src/who_is_server_value.rs b/src/who_is_server_value.rs index 62cb26e..82fd6e9 100644 --- a/src/who_is_server_value.rs +++ b/src/who_is_server_value.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use serde_json::Value; use validators::prelude::*; @@ -24,7 +26,7 @@ impl WhoIsServerValue { return Err(WhoIsError::MapError( "The server value is an object, but it has not a correct host \ string.", - )) + )); }, }; @@ -78,7 +80,7 @@ impl WhoIsServerValue { let host = match WhoIsHost::parse_str(host) { Ok(host) => host, Err(_) => { - return Err(WhoIsError::MapError("The server value is not a correct host string.")) + return Err(WhoIsError::MapError("The server value is not a correct host string.")); }, }; @@ -88,4 +90,30 @@ impl WhoIsServerValue { punycode: DEFAULT_PUNYCODE, }) } + + /// Return the domain text to send to this server. The input is expected to be already ASCII (Punycode) encoded. When `punycode` is `false`, it is decoded back to Unicode, which some servers (e.g. DENIC for `.de`) require. + pub(crate) fn encode_domain<'a>(&self, domain: &'a str) -> Cow<'a, str> { + if self.punycode { + Cow::Borrowed(domain) + } else { + Cow::Owned(validators::idna::domain_to_unicode(domain).0) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encode_domain_honors_punycode_flag() { + let mut server = WhoIsServerValue::from_string("whois.denic.de").unwrap(); + + // The default keeps the ASCII (Punycode) form. + assert_eq!("xn--mller-kva.de", server.encode_domain("xn--mller-kva.de").as_ref()); + + // A server with `punycode: false` receives the Unicode form. + server.punycode = false; + assert_eq!("müller.de", server.encode_domain("xn--mller-kva.de").as_ref()); + } } diff --git a/tests/test.rs b/tests/test.rs index 252b07f..ed7e703 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -15,7 +15,7 @@ fn test() { fn test_srv() { let mut who = WhoIs::from_host("whois.arin.net").unwrap(); - assert!(who.can_find_server_for_tld(".lotteryusa.us", "8.8.8.8:53")); + assert!(who.can_find_server_for_tld(".lotteryusa.us", "8.8.8.8:53").unwrap()); let result = who.lookup(WhoIsLookupOptions::from_string("lotteryusa.us").unwrap()).unwrap(); println!("{}", result);