Skip to main content

abuse_contact/
contact.rs

1//! What a lookup returns: an address, what it governs, and where it came from.
2
3use std::collections::HashSet;
4use std::fmt;
5
6use crate::error::ValidationError;
7
8/// An email address that accepts abuse reports.
9///
10/// The constructor rejects the placeholder strings that registries send in place of
11/// a real address, so a caller never mails `DATA REDACTED`.
12///
13/// ```
14/// use abuse_contact::{EmailAddress, ValidationError};
15///
16/// assert!(EmailAddress::new("abuse@example.com").is_ok());
17/// assert!(matches!(
18///     EmailAddress::new("DATA REDACTED"),
19///     Err(ValidationError::RedactedEmail { .. })
20/// ));
21/// ```
22#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct EmailAddress(String);
24
25/// Strings registries put in a contact field when they hide the real value.
26const PLACEHOLDERS: [&str; 3] = ["REDACTED", "NOT DISCLOSED", "PLEASE QUERY"];
27
28/// Addresses a source returns when it has no contact for the network.
29///
30/// One address stands for every network in the region, so it cannot be the contact
31/// of any one of them. Verified against six LACNIC networks in four countries, which
32/// all gave the same answer.
33///
34/// `ipadmin@lacnic.net` is not one. RDAP gives it only for the networks that LACNIC
35/// holds itself, and gives each other network the address of its holder.
36const WITHHELD: [&str; 1] = ["removed@lacnic.net"];
37
38impl EmailAddress {
39    /// Wraps an address that a source gave for abuse reports.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`ValidationError::RedactedEmail`] if the value is a registry
44    /// placeholder, and [`ValidationError::InvalidEmail`] if it is not shaped like an
45    /// address.
46    pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
47        let value = value.into().trim().to_owned();
48
49        let upper = value.to_uppercase();
50        if PLACEHOLDERS.iter().any(|p| upper.contains(p)) {
51            return Err(ValidationError::RedactedEmail { value });
52        }
53
54        let lower = value.to_lowercase();
55        if WITHHELD.contains(&lower.as_str()) {
56            return Err(ValidationError::WithheldEmail { value });
57        }
58
59        let problem = Self::problem(&value);
60        if let Some(problem) = problem {
61            return Err(ValidationError::InvalidEmail { value, problem });
62        }
63
64        Ok(Self(value))
65    }
66
67    /// Returns what is wrong with the value, or `None` when it is usable.
68    ///
69    /// This is a shape check, not a proof that the mailbox exists. It rejects what
70    /// cannot be an address so a bad value fails here and not in the mail queue.
71    fn problem(value: &str) -> Option<&'static str> {
72        if value.is_empty() {
73            return Some("it is empty");
74        }
75        if value.chars().any(|c| c.is_whitespace() || c.is_control()) {
76            return Some("it has a space or a control character");
77        }
78
79        let mut parts = value.split('@');
80        let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else {
81            return Some("it must have one \"@\"");
82        };
83
84        if local.is_empty() {
85            return Some("there is nothing before the \"@\"");
86        }
87        if domain.is_empty() {
88            return Some("there is nothing after the \"@\"");
89        }
90        if !domain.contains('.') {
91            return Some("the part after the \"@\" is not a domain name");
92        }
93
94        None
95    }
96
97    /// Returns the address.
98    pub fn as_str(&self) -> &str {
99        &self.0
100    }
101}
102
103impl fmt::Display for EmailAddress {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        f.write_str(&self.0)
106    }
107}
108
109/// What the contact has authority over.
110///
111/// The three are not interchangeable. A phishing site needs the registrar to suspend
112/// the name and the network to take down the host. Pick by what you want to happen.
113#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
114pub enum Scope {
115    /// The network that holds the IP address. This contact can null-route it.
116    Network,
117    /// The registrar of the domain name. This contact can suspend the name.
118    Registrar,
119    /// The operator of the domain itself. This contact runs the service.
120    Domain,
121}
122
123/// Where a contact came from.
124#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
125pub enum Source {
126    /// An RDAP entity with the `abuse` role, from the server named here.
127    Rdap {
128        /// The RDAP server that answered.
129        server: String,
130    },
131    /// The Abusix `abuse-contacts` DNS zone.
132    Abusix,
133    /// The abuse.net `contacts` DNS zone.
134    AbuseNet,
135    /// `abuse@` at the domain, as RFC 2142 requires.
136    Rfc2142,
137}
138
139impl Source {
140    /// Returns how much weight to give a contact from this source, highest first.
141    ///
142    /// RDAP comes first because a registry publishes it and keeps it current, but it
143    /// does not answer everywhere: one regional registry publishes no abuse entity.
144    /// RFC 2142 comes last because the address is a guess: the RFC says the mailbox
145    /// must exist, and many domains do not honour that.
146    fn rank(&self) -> u8 {
147        match self {
148            Source::Rdap { .. } => 0,
149            Source::Abusix => 1,
150            Source::AbuseNet => 2,
151            Source::Rfc2142 => 3,
152        }
153    }
154}
155
156/// One address that accepts reports, with what it governs and where it was found.
157#[derive(Clone, Debug, PartialEq, Eq, Hash)]
158pub struct Contact {
159    /// The address to write to.
160    pub email: EmailAddress,
161    /// What this contact has authority over.
162    pub scope: Scope,
163    /// Where the address came from.
164    pub source: Source,
165}
166
167/// Sorts contacts by source rank, then by address, and drops repeats.
168///
169/// The same address often comes from more than one source. The caller wants a list
170/// to read from the top, not the same mailbox four times.
171pub fn rank(mut contacts: Vec<Contact>) -> Vec<Contact> {
172    contacts.sort_by(|a, b| {
173        a.source
174            .rank()
175            .cmp(&b.source.rank())
176            .then_with(|| a.scope.cmp(&b.scope))
177            .then_with(|| a.email.cmp(&b.email))
178    });
179    // After the sort, the first contact for an address and scope is the one from the
180    // best source. A repeat can come after contacts from the same source, so it is not
181    // always next to the first one.
182    let mut seen = HashSet::new();
183    contacts.retain(|contact| seen.insert((contact.email.clone(), contact.scope)));
184    contacts
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn contact(email: &str, scope: Scope, source: Source) -> Contact {
192        Contact {
193            email: EmailAddress::new(email).unwrap(),
194            scope,
195            source,
196        }
197    }
198
199    #[test]
200    fn accepts_a_plain_address() {
201        assert_eq!(
202            EmailAddress::new("network-abuse@google.com")
203                .unwrap()
204                .as_str(),
205            "network-abuse@google.com"
206        );
207    }
208
209    #[test]
210    fn trims_surrounding_space() {
211        assert_eq!(
212            EmailAddress::new("  abuse@example.com\n").unwrap().as_str(),
213            "abuse@example.com"
214        );
215    }
216
217    #[test]
218    fn rejects_registry_placeholders() {
219        for value in [
220            "DATA REDACTED",
221            "REDACTED FOR PRIVACY",
222            "Not Disclosed",
223            "please query the RDDS service of the Registrar of Record",
224        ] {
225            assert!(
226                matches!(
227                    EmailAddress::new(value),
228                    Err(ValidationError::RedactedEmail { .. })
229                ),
230                "expected {value:?} to be rejected as a placeholder"
231            );
232        }
233    }
234
235    #[test]
236    fn rejects_the_address_a_source_sends_when_it_has_no_contact() {
237        for value in ["removed@lacnic.net", "REMOVED@LACNIC.NET"] {
238            assert!(
239                matches!(
240                    EmailAddress::new(value),
241                    Err(ValidationError::WithheldEmail { .. })
242                ),
243                "expected {value:?} to be rejected"
244            );
245        }
246    }
247
248    #[test]
249    fn keeps_a_real_address_at_the_same_domain() {
250        assert!(EmailAddress::new("ipadmin@lacnic.net").is_ok());
251    }
252
253    #[test]
254    fn rejects_values_that_are_not_addresses() {
255        for value in [
256            "",
257            "abuse",
258            "abuse@",
259            "@example.com",
260            "a@b@c.com",
261            "abuse@localhost",
262        ] {
263            assert!(
264                matches!(
265                    EmailAddress::new(value),
266                    Err(ValidationError::InvalidEmail { .. })
267                ),
268                "expected {value:?} to be rejected"
269            );
270        }
271    }
272
273    #[test]
274    fn rank_puts_rdap_first_and_rfc2142_last() {
275        let ranked = rank(vec![
276            contact("abuse@example.com", Scope::Domain, Source::Rfc2142),
277            contact("noc@example.com", Scope::Network, Source::Abusix),
278            contact(
279                "registrar-abuse@example.com",
280                Scope::Registrar,
281                Source::Rdap {
282                    server: "rdap.example.com".to_owned(),
283                },
284            ),
285        ]);
286
287        let got: Vec<&str> = ranked.iter().map(|c| c.email.as_str()).collect();
288
289        assert_eq!(
290            got,
291            [
292                "registrar-abuse@example.com",
293                "noc@example.com",
294                "abuse@example.com"
295            ]
296        );
297    }
298
299    #[test]
300    fn rank_drops_the_same_address_in_the_same_scope() {
301        let ranked = rank(vec![
302            contact("abuse@example.com", Scope::Network, Source::Abusix),
303            contact("abuse@example.com", Scope::Network, Source::AbuseNet),
304        ]);
305
306        assert_eq!(ranked.len(), 1);
307        assert_eq!(ranked[0].source, Source::Abusix);
308    }
309
310    #[test]
311    fn rank_drops_a_repeat_with_another_address_between() {
312        let rdap = || Source::Rdap {
313            server: "rdap.example".to_owned(),
314        };
315        let ranked = rank(vec![
316            contact("abuse@example.com", Scope::Network, Source::Abusix),
317            contact("abuse@example.com", Scope::Network, rdap()),
318            contact("noc@example.com", Scope::Network, rdap()),
319        ]);
320
321        assert_eq!(
322            ranked,
323            [
324                contact("abuse@example.com", Scope::Network, rdap()),
325                contact("noc@example.com", Scope::Network, rdap()),
326            ]
327        );
328    }
329
330    #[test]
331    fn rank_keeps_the_same_address_in_a_different_scope() {
332        let ranked = rank(vec![
333            contact("abuse@example.com", Scope::Network, Source::Abusix),
334            contact("abuse@example.com", Scope::Domain, Source::Rfc2142),
335        ]);
336
337        assert_eq!(ranked.len(), 2);
338    }
339}