Skip to main content

abuse_contact/
finder.rs

1//! One lookup that asks every source that answers for a target.
2
3use std::fmt;
4use std::net::IpAddr;
5
6use crate::cache::Cache;
7use crate::client::Client;
8use crate::contact::{Contact, Scope, rank};
9use crate::error::Error;
10use crate::query::{DomainName, Query};
11use crate::resolver::Resolver;
12
13/// Asks every source for a target, at the same time, and merges the answers.
14///
15/// An IP address goes to RDAP and Abusix. A domain name goes to RDAP, abuse.net and
16/// RFC 2142.
17///
18/// The finder holds the RDAP answers in a [`Cache`], so it does not ask a registry
19/// about the same network or the same domain again. [`Finder::with_cache`] sets how
20/// long an answer is held.
21///
22/// ```no_run
23/// use abuse_contact::{Client, Finder, Resolver};
24///
25/// # async fn run() -> Result<(), abuse_contact::Error> {
26/// let finder = Finder::new(Client::new().await?, Resolver::new()?);
27///
28/// let found = finder.lookup("196.216.2.1".parse::<std::net::IpAddr>().unwrap()).await?;
29/// for contact in &found.contacts {
30///     println!("{} ({:?})", contact.email, contact.scope);
31/// }
32/// for failure in &found.failures {
33///     eprintln!("{failure}");
34/// }
35/// # Ok(())
36/// # }
37/// ```
38#[derive(Clone, Debug)]
39pub struct Finder {
40    client: Client,
41    resolver: Resolver,
42    cache: Cache,
43}
44
45impl Finder {
46    /// Returns a finder that asks RDAP with this client and DNS with this resolver.
47    ///
48    /// It holds the RDAP answers in [`Cache::default`].
49    pub fn new(client: Client, resolver: Resolver) -> Self {
50        Self {
51            client,
52            resolver,
53            cache: Cache::default(),
54        }
55    }
56
57    /// Returns the finder with the RDAP answers held in this cache.
58    ///
59    /// Keep a clone of the cache to read its size or to clear it.
60    pub fn with_cache(self, cache: Cache) -> Self {
61        Self { cache, ..self }
62    }
63
64    /// Asks every source for the target and returns what they found.
65    ///
66    /// A source that fails does not fail the lookup. Its error is in
67    /// [`Found::failures`], and the contacts from the other sources are still given.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`Error::NotPublic`] for a private or reserved address. No source is
72    /// asked for it.
73    pub async fn lookup(&self, query: impl Into<Query>) -> Result<Found, Error> {
74        match query.into() {
75            Query::Ip(ip) => self.lookup_ip(ip).await,
76            Query::Domain(domain) => Ok(self.lookup_domain(&domain).await),
77        }
78    }
79
80    async fn lookup_ip(&self, ip: IpAddr) -> Result<Found, Error> {
81        // Each source makes this check too. Make it here as well, so that a query
82        // that no source can answer is an error and not two failures.
83        let ip = crate::query::unmap(ip);
84        if !crate::is_public(ip) {
85            return Err(Error::NotPublic {
86                target: ip.to_string(),
87            });
88        }
89
90        let (rdap, abusix) = tokio::join!(self.rdap_ip(ip), self.resolver.abusix(ip));
91
92        Ok(merge([(Origin::Rdap, rdap), (Origin::Abusix, abusix)]))
93    }
94
95    async fn lookup_domain(&self, domain: &DomainName) -> Found {
96        let (rdap, abuse_net, rfc2142) = tokio::join!(
97            self.rdap_domain(domain),
98            self.resolver.abuse_net(domain),
99            self.resolver.rfc2142(domain),
100        );
101
102        merge([
103            (Origin::Rdap, rdap),
104            (Origin::AbuseNet, abuse_net),
105            (Origin::Rfc2142, rfc2142.map(Vec::from_iter)),
106        ])
107    }
108
109    /// Returns the RDAP contacts for an address, from the cache when it holds them.
110    ///
111    /// The answer is held for the range the registry gives. A registry that holds no
112    /// record, or gives no range that holds the address, gives nothing to hold.
113    async fn rdap_ip(&self, ip: IpAddr) -> Result<Vec<Contact>, Error> {
114        if let Some(contacts) = self.cache.network(ip) {
115            return Ok(contacts);
116        }
117
118        let Some(record) = self.client.lookup_ip(ip).await? else {
119            return Ok(Vec::new());
120        };
121        let contacts = record.abuse_contacts(Scope::Network);
122
123        if let Some(range) = record.response.range()
124            && range.contains(&ip)
125        {
126            self.cache.put_network(range, contacts.clone());
127        }
128        Ok(contacts)
129    }
130
131    /// Returns the RDAP contacts for a domain, from the cache when it holds them.
132    ///
133    /// A registry that holds no record is held too: the name is not registered.
134    async fn rdap_domain(&self, domain: &DomainName) -> Result<Vec<Contact>, Error> {
135        if let Some(contacts) = self.cache.domain(domain) {
136            return Ok(contacts);
137        }
138
139        let contacts = self
140            .client
141            .lookup_domain(domain)
142            .await?
143            .map(|record| record.abuse_contacts(Scope::Registrar))
144            .unwrap_or_default();
145
146        self.cache.put_domain(domain.clone(), contacts.clone());
147        Ok(contacts)
148    }
149}
150
151/// What a lookup found: the contacts, and the sources that did not answer.
152#[derive(Debug, Default)]
153pub struct Found {
154    /// The contacts from every source that answered, ordered by [`rank`].
155    pub contacts: Vec<Contact>,
156    /// The sources that did not answer, with the reason.
157    ///
158    /// A source that answered with no contact is not here. Check this list before
159    /// you read an empty [`Found::contacts`] as "no contact is published".
160    pub failures: Vec<Failure>,
161}
162
163/// A source that did not answer.
164#[derive(Debug)]
165pub struct Failure {
166    /// The source that failed.
167    pub origin: Origin,
168    /// Why it failed.
169    pub error: Error,
170}
171
172impl fmt::Display for Failure {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        write!(f, "{} did not answer: {}", self.origin, self.error)
175    }
176}
177
178/// A source that [`Finder`] asks.
179///
180/// [`crate::Source`] names where a contact came from, and for RDAP it names the
181/// server that answered. A source that failed can have no server to name, so a
182/// failure names the source with this.
183#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
184pub enum Origin {
185    /// RDAP, from the server the bootstrap registry names.
186    Rdap,
187    /// The Abusix `abuse-contacts` DNS zone.
188    Abusix,
189    /// The abuse.net `contacts` DNS zone.
190    AbuseNet,
191    /// The MX and address lookups that decide whether `abuse@` at the domain is given.
192    Rfc2142,
193}
194
195impl fmt::Display for Origin {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        f.write_str(match self {
198            Origin::Rdap => "RDAP",
199            Origin::Abusix => "Abusix",
200            Origin::AbuseNet => "abuse.net",
201            Origin::Rfc2142 => "the RFC 2142 check",
202        })
203    }
204}
205
206/// Joins what each source gave into one ranked list, and keeps the failures beside it.
207fn merge(answers: impl IntoIterator<Item = (Origin, Result<Vec<Contact>, Error>)>) -> Found {
208    let mut contacts = Vec::new();
209    let mut failures = Vec::new();
210
211    for (origin, answer) in answers {
212        match answer {
213            Ok(found) => contacts.extend(found),
214            Err(error) => failures.push(Failure { origin, error }),
215        }
216    }
217
218    Found {
219        contacts: rank(contacts),
220        failures,
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::contact::{EmailAddress, Source};
228
229    fn contact(email: &str, scope: Scope, source: Source) -> Contact {
230        Contact {
231            email: EmailAddress::new(email).unwrap(),
232            scope,
233            source,
234        }
235    }
236
237    fn timeout(name: &str) -> Error {
238        Error::Dns {
239            name: name.to_owned(),
240            source: "timed out".into(),
241        }
242    }
243
244    #[test]
245    fn a_failed_source_keeps_the_contacts_of_the_others() {
246        let rdap = contact(
247            "abuse@registrar.example",
248            Scope::Registrar,
249            Source::Rdap {
250                server: "rdap.example".to_owned(),
251            },
252        );
253
254        let found = merge([
255            (Origin::Rdap, Ok(vec![rdap])),
256            (
257                Origin::AbuseNet,
258                Err(timeout("example.com.contacts.abuse.net.")),
259            ),
260        ]);
261
262        let emails: Vec<&str> = found.contacts.iter().map(|c| c.email.as_str()).collect();
263        assert_eq!(emails, ["abuse@registrar.example"]);
264        assert_eq!(found.failures.len(), 1);
265        assert_eq!(found.failures[0].origin, Origin::AbuseNet);
266    }
267
268    #[test]
269    fn the_contacts_are_ranked_and_a_repeat_is_dropped() {
270        let found = merge([
271            (
272                Origin::Abusix,
273                Ok(vec![contact(
274                    "abuse@example.net",
275                    Scope::Network,
276                    Source::Abusix,
277                )]),
278            ),
279            (
280                Origin::Rdap,
281                Ok(vec![contact(
282                    "abuse@example.net",
283                    Scope::Network,
284                    Source::Rdap {
285                        server: "rdap.example".to_owned(),
286                    },
287                )]),
288            ),
289        ]);
290
291        assert_eq!(
292            found.contacts,
293            [contact(
294                "abuse@example.net",
295                Scope::Network,
296                Source::Rdap {
297                    server: "rdap.example".to_owned()
298                },
299            )]
300        );
301        assert_eq!(found.failures.len(), 0);
302    }
303
304    #[test]
305    fn every_source_failing_gives_no_contacts_and_every_failure() {
306        let found = merge([
307            (Origin::Rdap, Err(timeout("rdap.example"))),
308            (Origin::Abusix, Err(timeout("abusix.example"))),
309        ]);
310
311        assert_eq!(found.contacts, []);
312        let origins: Vec<Origin> = found.failures.iter().map(|f| f.origin).collect();
313        assert_eq!(origins, [Origin::Rdap, Origin::Abusix]);
314    }
315
316    #[test]
317    fn a_failure_says_which_source_failed_and_why() {
318        let failure = Failure {
319            origin: Origin::Rdap,
320            error: Error::NoServer {
321                target: "example.invalid".to_owned(),
322            },
323        };
324
325        assert!(
326            failure
327                .to_string()
328                .starts_with("RDAP did not answer: no RDAP server answers for example.invalid"),
329            "{failure}"
330        );
331    }
332}