use crate::error::DomainCheckError;
use crate::types::{CheckConfig, DomainResult};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const NODE_NAME: &str = "_for-sale";
const VERSION_TAG: &str = "v=FORSALE1;";
const MAX_VALUE_LEN: usize = 239;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ForSaleInfo {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub uris: Vec<ForSaleUri>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prices: Vec<ForSalePrice>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub texts: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub codes: Vec<String>,
}
impl ForSaleInfo {
pub fn is_empty(&self) -> bool {
self.uris.is_empty()
&& self.prices.is_empty()
&& self.texts.is_empty()
&& self.codes.is_empty()
}
pub fn first_trusted_uri(&self) -> Option<&ForSaleUri> {
self.uris.iter().find(|u| u.trusted)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ForSaleUri {
pub value: String,
pub scheme: String,
pub trusted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ForSalePrice {
pub currency: String,
pub amount: String,
}
impl std::fmt::Display for ForSalePrice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.currency, self.amount)
}
}
pub fn record_name(domain: &str) -> Option<String> {
let domain = domain.trim_end_matches('.').to_ascii_lowercase();
if domain.is_empty() || domain == "arpa" || domain.ends_with(".arpa") {
return None;
}
Some(format!("{NODE_NAME}.{domain}."))
}
pub fn parse_txt_records(records: &[Vec<Vec<u8>>]) -> Option<ForSaleInfo> {
let mut info = ForSaleInfo::default();
let mut valid = false;
let mut seen = HashSet::new();
for record in records {
let text = String::from_utf8_lossy(&record.concat()).into_owned();
let Some(content) = text.strip_prefix(VERSION_TAG) else {
continue;
};
valid = true;
let content = content.trim_start_matches(' ');
if !seen.insert(content.to_string()) {
continue;
}
let Some((tag, value)) = content.split_once('=') else {
continue;
};
match tag {
"ftxt" => push_text(&mut info.texts, value),
"fcod" => push_text(&mut info.codes, value),
"fval" => info.prices.extend(parse_price(value)),
"furi" => info.uris.extend(parse_uri(value)),
_ => {}
}
}
if !valid {
return None;
}
info.texts.sort();
info.codes.sort();
info.uris.sort_by(|a, b| a.value.cmp(&b.value));
info.prices
.sort_by(|a, b| (&a.currency, &a.amount).cmp(&(&b.currency, &b.amount)));
Some(info)
}
fn push_text(list: &mut Vec<String>, value: &str) {
if value.is_empty() || value.len() > MAX_VALUE_LEN {
return;
}
let clean = sanitize(value);
if !clean.is_empty() {
list.push(clean);
}
}
fn parse_price(value: &str) -> Option<ForSalePrice> {
let value = value.trim();
if value.len() < 2 || value.len() > MAX_VALUE_LEN {
return None;
}
let split = value.find(|c: char| !c.is_ascii_uppercase())?;
let (currency, amount) = value.split_at(split);
let (int, frac) = match amount.split_once('.') {
Some((i, f)) => (i, Some(f)),
None => (amount, None),
};
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
if currency.is_empty() || !digits(int) || frac.is_some_and(|f| !digits(f)) {
return None;
}
Some(ForSalePrice {
currency: currency.to_string(),
amount: amount.to_string(),
})
}
fn parse_uri(value: &str) -> Option<ForSaleUri> {
let value = sanitize(value);
if value.contains(' ') {
return None;
}
let (scheme, rest) = value.split_once(':')?;
let mut chars = scheme.chars();
let valid_scheme = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
if !valid_scheme || rest.is_empty() {
return None;
}
let scheme = scheme.to_ascii_lowercase();
let trusted = match scheme.as_str() {
"http" | "https" => rest
.strip_prefix("//")
.is_some_and(|r| !r.is_empty() && !r.starts_with('/')),
"mailto" | "tel" => true,
_ => false,
};
Some(ForSaleUri {
value,
scheme,
trusted,
})
}
pub(crate) fn sanitize(value: &str) -> String {
value
.chars()
.filter_map(|c| match c {
'\t' | '\n' | '\r' | '\u{2028}' | '\u{2029}' => Some(' '),
c if c.is_control() || is_bidi_control(c) || is_invisible(c) => None,
c => Some(c),
})
.collect::<String>()
.trim()
.to_string()
}
fn is_invisible(c: char) -> bool {
matches!(
c,
'\u{00AD}' | '\u{180E}' | '\u{200B}' | '\u{2060}'..='\u{2064}' | '\u{FEFF}' | '\u{E0000}'..='\u{E007F}'
)
}
fn is_bidi_control(c: char) -> bool {
matches!(
c,
'\u{200E}' | '\u{200F}' | '\u{061C}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'
)
}
const LOOKUP_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ForSaleFailures {
pub count: usize,
pub last_error: String,
}
#[cfg(feature = "forsale")]
#[derive(Debug)]
enum Outcome {
Found(ForSaleInfo),
NotForSale,
Failed(String),
}
#[derive(Clone)]
pub(crate) struct ForSaleLookup {
#[cfg(feature = "forsale")]
resolver: Option<hickory_resolver::TokioResolver>,
#[cfg_attr(not(feature = "forsale"), allow(dead_code))]
servers: Arc<str>,
#[cfg_attr(not(feature = "forsale"), allow(dead_code))]
timeout: Duration,
failures: Arc<Mutex<Option<ForSaleFailures>>>,
}
impl ForSaleLookup {
pub(crate) fn new(config: &CheckConfig, dns_server: Option<IpAddr>) -> Self {
let timeout = config.timeout.min(LOOKUP_TIMEOUT);
let failures = Arc::default();
#[cfg(feature = "forsale")]
{
let (resolver, servers) = if config.check_for_sale {
build_resolver(dns_server).map_or((None, "".into()), |(r, s)| (Some(r), s))
} else {
(None, "".into())
};
Self {
resolver,
servers,
timeout,
failures,
}
}
#[cfg(not(feature = "forsale"))]
{
let _ = (config.check_for_sale, dns_server);
Self {
servers: "".into(),
timeout,
failures,
}
}
}
pub(crate) fn is_active(&self) -> bool {
#[cfg(feature = "forsale")]
{
self.resolver.is_some()
}
#[cfg(not(feature = "forsale"))]
{
false
}
}
pub(crate) fn failures(&self) -> Option<ForSaleFailures> {
self.failures.lock().ok()?.clone()
}
pub(crate) async fn annotate(&self, result: &mut DomainResult) {
if result.available != Some(true) {
result.for_sale = self.lookup_domain(&result.domain).await;
}
}
pub(crate) async fn annotate_error(
&self,
domain: &str,
error: DomainCheckError,
) -> Result<DomainResult, DomainCheckError> {
if matches!(error, DomainCheckError::InvalidDomain { .. }) || !self.is_active() {
return Err(error);
}
match self.lookup_domain(domain).await {
Some(info) => Ok(DomainResult {
domain: domain.to_string(),
available: None,
info: None,
check_duration: None,
method_used: crate::types::CheckMethod::Unknown,
error_message: Some(error.to_string()),
for_sale: Some(info),
}),
None => Err(error),
}
}
async fn lookup_domain(&self, domain: &str) -> Option<ForSaleInfo> {
#[cfg(feature = "forsale")]
if let Some(resolver) = &self.resolver {
match lookup(resolver, domain, self.timeout).await {
Outcome::Found(info) => return Some(info),
Outcome::NotForSale => {}
Outcome::Failed(error) => self.record_failure(error),
}
}
#[cfg(not(feature = "forsale"))]
let _ = domain;
None
}
#[cfg(feature = "forsale")]
fn record_failure(&self, error: String) {
if let Ok(mut failures) = self.failures.lock() {
let entry = failures.get_or_insert(ForSaleFailures {
count: 0,
last_error: String::new(),
});
entry.count += 1;
entry.last_error = format!("DNS server {}: {error}", self.servers);
}
}
}
#[cfg(feature = "forsale")]
fn build_resolver(
dns_server: Option<IpAddr>,
) -> Option<(hickory_resolver::TokioResolver, Arc<str>)> {
use hickory_resolver::config::{NameServerConfig, ResolverConfig, ResolverOpts};
use hickory_resolver::net::runtime::TokioRuntimeProvider;
let (config, mut options) = match dns_server {
Some(ip) => (
ResolverConfig::from_name_servers(vec![NameServerConfig::udp_and_tcp(ip)]),
ResolverOpts::default(),
),
None => hickory_resolver::system_conf::read_system_conf().ok()?,
};
let mut servers: Vec<String> = config
.name_servers()
.iter()
.map(|ns| ns.ip.to_string())
.collect();
servers.dedup();
options.num_concurrent_reqs = 1;
let mut builder = hickory_resolver::TokioResolver::builder_with_config(
config,
TokioRuntimeProvider::default(),
);
*builder.options_mut() = options;
Some((builder.build().ok()?, servers.join(", ").into()))
}
#[cfg(feature = "forsale")]
fn is_domain_side_error(error: &hickory_resolver::net::NetError) -> bool {
use hickory_resolver::net::{DnsError, NetError};
use hickory_resolver::proto::op::ResponseCode;
matches!(
error,
NetError::Dns(DnsError::ResponseCode(ResponseCode::ServFail))
)
}
#[cfg(all(test, feature = "forsale"))]
impl ForSaleLookup {
fn with_nameserver(ip: IpAddr, timeout: Duration) -> Self {
let config = CheckConfig::default()
.with_for_sale(true)
.with_timeout(timeout);
Self::new(&config, Some(ip))
}
}
#[cfg(feature = "forsale")]
async fn lookup(
resolver: &hickory_resolver::TokioResolver,
domain: &str,
timeout: Duration,
) -> Outcome {
use hickory_resolver::proto::rr::RData;
let Some(name) = record_name(domain) else {
return Outcome::NotForSale;
};
let answer = match tokio::time::timeout(timeout, resolver.txt_lookup(name.as_str())).await {
Err(_) => return Outcome::Failed(format!("no answer within {timeout:?}")),
Ok(Err(e)) if e.is_no_records_found() || e.is_nx_domain() || is_domain_side_error(&e) => {
return Outcome::NotForSale
}
Ok(Err(e)) => return Outcome::Failed(e.to_string()),
Ok(Ok(answer)) => answer,
};
let records: Vec<Vec<Vec<u8>>> = answer
.answers()
.iter()
.filter_map(|record| match &record.data {
RData::TXT(txt) => Some(txt.txt_data.iter().map(|s| s.to_vec()).collect()),
_ => None,
})
.collect();
match parse_txt_records(&records) {
Some(info) => Outcome::Found(info),
None => Outcome::NotForSale,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(s: &str) -> Vec<Vec<u8>> {
vec![s.as_bytes().to_vec()]
}
fn parse(records: &[&str]) -> Option<ForSaleInfo> {
let rs: Vec<Vec<Vec<u8>>> = records.iter().map(|s| rec(s)).collect();
parse_txt_records(&rs)
}
#[test]
fn version_tag_variants() {
assert!(parse(&["v=FORSALE1;"]).is_some());
assert!(parse(&["v=FORSALE1"]).is_none());
assert!(parse(&["v=FORSALE"]).is_none());
assert!(parse(&["v=FORSALE;"]).is_none());
assert!(parse(&["v=FORSALE0;"]).is_none());
assert!(parse(&["V=FORSALE1;"]).is_none(), "case-sensitive");
assert!(parse(&["v=FORSALE10;"]).is_none());
assert!(parse(&[" v=FORSALE1;"]).is_none(), "must start the record");
}
#[test]
fn empty_rrset_is_not_for_sale() {
assert!(parse(&[]).is_none());
assert!(parse(&["v=spf1 -all", "I am for sale"]).is_none());
}
#[test]
fn version_only_is_for_sale_and_empty() {
let info = parse(&["v=FORSALE1;"]).unwrap();
assert!(info.is_empty());
}
#[test]
fn invalid_or_unknown_content_still_for_sale() {
for r in [
"v=FORSALE1;fcod=",
"v=FORSALE1;foo=bar",
"v=FORSALE1;garbage",
"v=FORSALE1;fval=cheap",
] {
let info = parse(&[r]).unwrap_or_else(|| panic!("{r} should be for sale"));
assert!(info.is_empty(), "{r}");
}
}
#[test]
fn spaces_after_version_tolerated() {
let info = parse(&["v=FORSALE1; ftxt=hello"]).unwrap();
assert_eq!(info.texts, vec!["hello"]);
}
#[test]
fn records_without_version_are_ignored_alongside_valid() {
let info = parse(&[
"This line is not part of the ForSale record.",
"v=FORSALE1;ftxt=hi",
])
.unwrap();
assert_eq!(info.texts, vec!["hi"]);
}
#[test]
fn multi_string_record_is_concatenated() {
let split = vec![
b"v=FORSALE1;".to_vec(),
b"ftxt=foo".to_vec(),
b" bar".to_vec(),
];
let info = parse_txt_records(&[split]).unwrap();
assert_eq!(info.texts, vec!["foo bar"]);
}
#[test]
fn multi_string_split_inside_utf8_character() {
let s = "v=FORSALE1;ftxt=𝙷i".as_bytes();
let (a, b) = s.split_at(18); let info = parse_txt_records(&[vec![a.to_vec(), b.to_vec()]]).unwrap();
assert_eq!(info.texts, vec!["𝙷i"]);
}
#[test]
fn two_tags_in_one_record_is_for_sale_without_details() {
let info = parse(&["v=FORSALE1;fval=USD1000000;furi=mailto:forsale31337@nohats.ca?subject=I want to buy nohats.ca"]).unwrap();
assert!(info.is_empty());
}
#[test]
fn duplicates_are_collapsed_and_output_sorted() {
let info = parse(&[
"v=FORSALE1;ftxt=b",
"v=FORSALE1;ftxt=a",
"v=FORSALE1;ftxt=b",
"v=FORSALE1;fval=USD5",
"v=FORSALE1;fval=EUR9",
])
.unwrap();
assert_eq!(info.texts, vec!["a", "b"]);
assert_eq!(info.prices[0].currency, "EUR");
assert_eq!(info.prices[1].currency, "USD");
}
#[test]
fn all_tags_parse() {
let info = parse(&[
"v=FORSALE1;furi=https://example.nl/for-sale.txt",
"v=FORSALE1;fval=EUR100000000",
"v=FORSALE1;ftxt=See the URL for important information!",
"v=FORSALE1;fcod=NLFS-NGYyYjEyZWY",
])
.unwrap();
assert_eq!(info.uris[0].value, "https://example.nl/for-sale.txt");
assert!(info.uris[0].trusted);
assert_eq!(info.prices[0].to_string(), "EUR 100000000");
assert_eq!(info.texts[0], "See the URL for important information!");
assert_eq!(info.codes[0], "NLFS-NGYyYjEyZWY");
}
#[test]
fn value_runs_to_end_of_record() {
let info = parse(&["v=FORSALE1;fcod=TRIP-confusing;ftxt=dont_do_this"]).unwrap();
assert_eq!(info.codes, vec!["TRIP-confusing;ftxt=dont_do_this"]);
assert!(info.texts.is_empty());
let info = parse(&["v=FORSALE1;furi=https://x.example/a;b=c?d=e"]).unwrap();
assert_eq!(info.uris[0].value, "https://x.example/a;b=c?d=e");
}
#[test]
fn unicode_text_kept() {
let info = parse(&["v=FORSALE1;ftxt=🤓"]).unwrap();
assert_eq!(info.texts, vec!["🤓"]);
}
#[test]
fn invalid_utf8_replaced() {
let bad = vec![[b"v=FORSALE1;ftxt=a".as_slice(), &[0xFF], b"b"].concat()];
let info = parse_txt_records(&[bad]).unwrap();
assert_eq!(info.texts, vec!["a\u{FFFD}b"]);
}
#[test]
fn value_length_limits() {
let ok = format!("v=FORSALE1;ftxt={}", "a".repeat(239));
let too_long = format!("v=FORSALE1;ftxt={}", "a".repeat(240));
assert_eq!(parse(&[&ok]).unwrap().texts.len(), 1);
assert!(parse(&[&too_long]).unwrap().texts.is_empty());
}
#[test]
fn fval_rules() {
let p = |v: &str| parse(&[&format!("v=FORSALE1;fval={v}")]).unwrap().prices;
assert_eq!(p("EUR999")[0].amount, "999");
assert_eq!(p("BTC0.00010")[0].amount, "0.00010");
assert_eq!(p(" USD5 ")[0].currency, "USD");
for bad in [
"eur999", "EUR", "999", "EUR9.", "EUR.5", "EUR 999", "EUR9,99", "E",
] {
assert!(p(bad).is_empty(), "{bad} should be rejected");
}
}
#[test]
fn furi_trust() {
let u = |v: &str| parse(&[&format!("v=FORSALE1;furi={v}")]).unwrap().uris;
assert!(u("https://a.example/")[0].trusted);
assert!(u("http://a.example")[0].trusted);
assert!(u("mailto:sales@example.com")[0].trusted);
assert!(u("tel:+31123456789")[0].trusted);
let ftp = u("ftp://a.example/");
assert!(!ftp[0].trusted);
assert_eq!(ftp[0].scheme, "ftp");
assert!(!u("javascript:alert(1)")[0].trusted);
assert_eq!(u("HTTPS://A.example")[0].scheme, "https");
for bad in [
"https:///nohost",
"not a uri",
"https://a b",
"noscheme",
"1http://x",
":x",
] {
let uris = u(bad);
assert!(uris.iter().all(|x| !x.trusted), "{bad} must not be trusted");
}
assert!(u("not a uri").is_empty());
assert!(u("noscheme").is_empty());
}
#[test]
fn sanitize_strips_terminal_escapes() {
assert_eq!(sanitize("a\u{1b}[31mred\u{1b}[0m"), "a[31mred[0m");
assert_eq!(sanitize("x\u{9b}31my"), "x31my");
assert_eq!(sanitize("bell\u{7}"), "bell");
assert_eq!(sanitize("a\tb\nc\rd"), "a b c d");
}
#[test]
fn sanitize_strips_bidi_controls() {
assert_eq!(sanitize("abc\u{202E}fed"), "abcfed");
assert_eq!(sanitize("\u{2066}x\u{2069}\u{200F}\u{061C}"), "x");
}
#[test]
fn sanitize_strips_invisible_format_characters() {
let smuggled: String = "Great"
.chars()
.chain(
"ignore previous"
.chars()
.map(|c| char::from_u32(0xE0000 + c as u32).unwrap()),
)
.collect();
assert_eq!(sanitize(&smuggled), "Great");
assert_eq!(
sanitize("a\u{200B}b\u{2060}c\u{FEFF}d\u{00AD}e\u{180E}f"),
"abcdef"
);
assert_eq!(sanitize("line\u{2028}para\u{2029}end"), "line para end");
assert_eq!(sanitize("👩\u{200D}💻"), "👩\u{200D}💻");
assert_eq!(sanitize("می\u{200C}خواهم"), "می\u{200C}خواهم");
}
#[test]
fn text_that_sanitizes_to_empty_is_dropped() {
let info = parse(&["v=FORSALE1;ftxt=\u{1b}\u{7}"]).unwrap();
assert!(info.texts.is_empty());
}
#[test]
fn record_name_rules() {
assert_eq!(
record_name("example.com").as_deref(),
Some("_for-sale.example.com.")
);
assert_eq!(
record_name("Example.COM.").as_deref(),
Some("_for-sale.example.com.")
);
assert_eq!(record_name("51.198.in-addr.arpa"), None);
assert_eq!(record_name("arpa"), None);
assert_eq!(record_name(""), None);
assert_eq!(record_name("."), None);
}
#[test]
fn json_shape_omits_empty_lists() {
let info = parse(&["v=FORSALE1;fval=EUR5"]).unwrap();
let json = serde_json::to_value(&info).unwrap();
assert_eq!(
json,
serde_json::json!({"prices":[{"currency":"EUR","amount":"5"}]})
);
let empty = serde_json::to_value(parse(&["v=FORSALE1;"]).unwrap()).unwrap();
assert_eq!(empty, serde_json::json!({}));
}
#[cfg(feature = "forsale")]
fn taken(domain: &str) -> crate::types::DomainResult {
crate::types::DomainResult {
domain: domain.to_string(),
available: Some(false),
info: None,
check_duration: None,
method_used: crate::types::CheckMethod::Rdap,
error_message: None,
for_sale: None,
}
}
#[test]
fn lookup_inactive_when_not_requested() {
let lookup = ForSaleLookup::new(&crate::types::CheckConfig::default(), None);
assert!(!lookup.is_active());
}
#[cfg(not(feature = "forsale"))]
#[test]
fn lookup_inactive_without_feature() {
let config = crate::types::CheckConfig::default().with_for_sale(true);
assert!(!ForSaleLookup::new(&config, None).is_active());
}
#[cfg(feature = "forsale")]
#[tokio::test]
async fn annotate_skips_available_results() {
let config = crate::types::CheckConfig::default().with_for_sale(true);
let lookup = ForSaleLookup::new(&config, None);
let mut r = taken("example.nl");
r.available = Some(true);
lookup.annotate(&mut r).await;
assert!(r.for_sale.is_none());
}
#[cfg(feature = "forsale")]
#[tokio::test]
async fn annotate_looks_up_unknown_results() {
let config = crate::types::CheckConfig::default().with_for_sale(true);
let lookup = ForSaleLookup::new(&config, None);
let mut r = taken("example.nl");
r.available = None;
lookup.annotate(&mut r).await;
assert!(r.for_sale.is_some(), "example.nl publishes _for-sale");
assert_eq!(r.available, None, "status must not change");
}
#[cfg(feature = "forsale")]
#[tokio::test]
async fn failed_check_with_record_becomes_unknown_for_sale() {
let config = crate::types::CheckConfig::default().with_for_sale(true);
let lookup = ForSaleLookup::new(&config, None);
let err = DomainCheckError::whois("example.nl", "WHOIS server refused the query");
let r = lookup
.annotate_error("example.nl", err)
.await
.expect("record turns the error into a result");
assert_eq!(r.available, None);
assert!(r.for_sale.is_some());
assert!(r.error_message.is_some(), "the original error is kept");
}
#[cfg(feature = "forsale")]
#[tokio::test]
async fn failed_check_without_record_stays_an_error() {
let config = crate::types::CheckConfig::default().with_for_sale(true);
let lookup = ForSaleLookup::new(&config, None);
let err = DomainCheckError::whois("google.com", "boom");
assert!(lookup.annotate_error("google.com", err).await.is_err());
let invalid = DomainCheckError::invalid_domain("bad..name", "empty label");
assert!(matches!(
lookup.annotate_error("bad..name", invalid).await,
Err(DomainCheckError::InvalidDomain { .. })
));
}
#[cfg(feature = "forsale")]
#[tokio::test]
async fn no_record_is_not_a_failure() {
let config = crate::types::CheckConfig::default().with_for_sale(true);
let lookup = ForSaleLookup::new(&config, None);
let mut r = taken("google.com");
lookup.annotate(&mut r).await;
assert!(r.for_sale.is_none());
assert_eq!(lookup.failures(), None);
}
#[cfg(feature = "forsale")]
#[tokio::test]
async fn dns_lookup_example_nl_without_rdap() {
let config = crate::types::CheckConfig::default().with_for_sale(true);
let for_sale = ForSaleLookup::new(&config, None);
let resolver = for_sale.resolver.as_ref().expect("system resolver");
let Outcome::Found(info) = lookup(resolver, "example.nl", LOOKUP_TIMEOUT).await else {
panic!("_for-sale.example.nl is published by SIDN");
};
assert!(info.prices.iter().any(|p| p.to_string() == "EUR 100000000"));
assert!(info.first_trusted_uri().is_some());
assert!(!info.codes.is_empty());
}
#[cfg(feature = "forsale")]
#[tokio::test]
async fn hanging_resolver_is_capped_and_verdict_unchanged() {
let timeout = std::time::Duration::from_millis(500);
let for_sale = ForSaleLookup::with_nameserver("192.0.2.1".parse().unwrap(), timeout);
assert!(for_sale.is_active());
let mut r = taken("example.nl");
r.error_message = Some("kept".into());
let start = std::time::Instant::now();
for_sale.annotate(&mut r).await;
assert!(
start.elapsed() < std::time::Duration::from_secs(2),
"lookup took {:?}",
start.elapsed()
);
assert!(r.for_sale.is_none());
assert_eq!(r.available, Some(false));
assert_eq!(r.method_used, crate::types::CheckMethod::Rdap);
assert_eq!(r.error_message.as_deref(), Some("kept"));
let failures = for_sale.failures().expect("timeout is a failure");
assert_eq!(failures.count, 1);
assert!(failures.last_error.contains("192.0.2.1"), "{failures:?}");
}
#[cfg(feature = "forsale")]
#[test]
fn servfail_is_the_domains_problem_not_the_resolvers() {
use hickory_resolver::net::{DnsError, NetError};
use hickory_resolver::proto::op::ResponseCode;
let servfail = NetError::Dns(DnsError::ResponseCode(ResponseCode::ServFail));
assert!(is_domain_side_error(&servfail));
let refused = NetError::Dns(DnsError::ResponseCode(ResponseCode::Refused));
assert!(!is_domain_side_error(&refused));
assert!(!is_domain_side_error(&NetError::Timeout));
}
#[cfg(feature = "forsale")]
#[tokio::test]
async fn failures_are_shared_between_clones() {
let timeout = std::time::Duration::from_millis(200);
let for_sale = ForSaleLookup::with_nameserver("192.0.2.1".parse().unwrap(), timeout);
let clone = for_sale.clone();
clone.annotate(&mut taken("a.example")).await;
for_sale.annotate(&mut taken("b.example")).await;
assert_eq!(for_sale.failures().map(|f| f.count), Some(2));
}
#[cfg(feature = "forsale")]
#[tokio::test]
async fn dns_lookup_large_rrset_falls_back_to_tcp() {
let config = crate::types::CheckConfig::default().with_for_sale(true);
let for_sale = ForSaleLookup::new(&config, None);
let resolver = for_sale.resolver.as_ref().expect("system resolver");
let outcome = lookup(resolver, "j78.nl", std::time::Duration::from_secs(15)).await;
if matches!(outcome, Outcome::Failed(_))
&& cfg!(target_os = "macos")
&& std::env::var_os("CI").is_some()
{
eprintln!("skipped: CI resolver does not answer DNS over TCP");
return;
}
let Outcome::Found(info) = outcome else {
panic!("_for-sale.j78.nl is published by SIDN, got {outcome:?}");
};
assert!(info.prices.iter().any(|p| p.to_string() == "EUR 300000"));
}
}