1use std::net::IpAddr;
8use std::ops::RangeInclusive;
9
10use serde::Deserialize;
11
12use crate::contact::{Contact, EmailAddress, Scope, Source};
13
14#[derive(Clone, Debug, Default, Deserialize)]
16pub struct Response {
17 #[serde(default)]
19 pub entities: Vec<Entity>,
20 #[serde(default)]
22 pub links: Vec<Link>,
23 #[serde(rename = "startAddress")]
28 pub start_address: Option<serde_json::Value>,
29 #[serde(rename = "endAddress")]
31 pub end_address: Option<serde_json::Value>,
32}
33
34#[derive(Clone, Debug, Default, Deserialize)]
40pub struct Entity {
41 #[serde(default)]
43 pub roles: Vec<String>,
44 pub handle: Option<String>,
46 #[serde(rename = "vcardArray")]
48 pub vcard_array: Option<serde_json::Value>,
49 #[serde(default)]
51 pub entities: Vec<Entity>,
52}
53
54#[derive(Clone, Debug, Deserialize)]
56pub struct Link {
57 pub rel: Option<String>,
59 pub href: Option<String>,
61}
62
63fn address(value: &serde_json::Value) -> Option<IpAddr> {
65 value.as_str()?.parse().ok()
66}
67
68const ABUSE_ROLE: &str = "abuse";
70
71impl Response {
72 pub fn range(&self) -> Option<RangeInclusive<IpAddr>> {
78 let start = address(self.start_address.as_ref()?)?;
79 let end = address(self.end_address.as_ref()?)?;
80
81 let same_family = start.is_ipv4() == end.is_ipv4();
82 (same_family && start <= end).then_some(start..=end)
83 }
84
85 pub fn related_href(&self) -> Option<&str> {
92 self.links
93 .iter()
94 .find(|link| link.rel.as_deref() == Some("related"))
95 .and_then(|link| link.href.as_deref())
96 }
97
98 pub fn abuse_contacts(&self, scope: Scope, server: &str) -> Vec<Contact> {
106 let mut contacts = Vec::new();
107 collect(&self.entities, scope, server, &mut contacts);
108
109 let mut seen = std::collections::HashSet::new();
110 contacts.retain(|contact| seen.insert(contact.email.clone()));
111 contacts
112 }
113}
114
115fn collect(entities: &[Entity], scope: Scope, server: &str, out: &mut Vec<Contact>) {
118 for entity in entities {
119 if entity.roles.iter().any(|role| role == ABUSE_ROLE) {
120 out.extend(
121 emails(entity.vcard_array.as_ref())
122 .into_iter()
123 .filter_map(|value| EmailAddress::new(value).ok())
124 .map(|email| Contact {
125 email,
126 scope,
127 source: Source::Rdap {
128 server: server.to_owned(),
129 },
130 }),
131 );
132 }
133
134 collect(&entity.entities, scope, server, out);
135 }
136}
137
138const DEFAULT_PREF: u32 = 100;
142
143fn emails(vcard_array: Option<&serde_json::Value>) -> Vec<&str> {
155 let Some(properties) = vcard_array
156 .and_then(|v| v.get(1))
157 .and_then(|v| v.as_array())
158 else {
159 return Vec::new();
160 };
161
162 let mut found: Vec<(u32, &str)> = properties
163 .iter()
164 .filter_map(|property| property.as_array())
165 .filter(|property| property.first().and_then(|n| n.as_str()) == Some("email"))
166 .filter_map(|property| {
167 let value = property.get(3).and_then(|value| value.as_str())?;
168 Some((preference(property.get(1)), value))
169 })
170 .collect();
171
172 found.sort_by_key(|(pref, _)| *pref);
174 found.into_iter().map(|(_, value)| value).collect()
175}
176
177fn preference(parameters: Option<&serde_json::Value>) -> u32 {
181 let Some(pref) = parameters.and_then(|p| p.get("pref")) else {
182 return DEFAULT_PREF;
183 };
184
185 if let Some(text) = pref.as_str() {
186 return text.parse().unwrap_or(DEFAULT_PREF);
187 }
188
189 pref.as_u64()
190 .and_then(|n| u32::try_from(n).ok())
191 .unwrap_or(DEFAULT_PREF)
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 const ARIN_IP: &str = r#"{
200 "entities": [{
201 "handle": "GOGL",
202 "roles": ["registrant"],
203 "vcardArray": ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "Google LLC"]]],
204 "entities": [{
205 "handle": "ABUSE5250-ARIN",
206 "roles": ["abuse"],
207 "vcardArray": ["vcard", [
208 ["fn", {}, "text", "Abuse"],
209 ["email", {}, "text", "network-abuse@google.com"],
210 ["tel", {"type": ["work", "voice"]}, "text", "+1-650-253-0000"]
211 ]]
212 }, {
213 "handle": "ZG39-ARIN",
214 "roles": ["administrative", "technical"],
215 "vcardArray": ["vcard", [["email", {}, "text", "arin-contact@google.com"]]]
216 }]
217 }]
218 }"#;
219
220 const RIPE_IP: &str = r#"{
222 "entities": [{
223 "handle": "MDIR-RIPE",
224 "roles": ["administrative"],
225 "vcardArray": ["vcard", [["fn", {}, "text", "Managing Director"]]]
226 }, {
227 "handle": "OPS4-RIPE",
228 "roles": ["abuse"],
229 "vcardArray": ["vcard", [
230 ["fn", {}, "text", "RIPE NCC Operations"],
231 ["email", {}, "text", "abuse@ripe.net"]
232 ]]
233 }]
234 }"#;
235
236 const REGISTRY_DOMAIN: &str = r#"{
238 "entities": [{
239 "roles": ["registrar"],
240 "vcardArray": ["vcard", [["fn", {}, "text", "Cloudflare, Inc."]]],
241 "entities": [{
242 "roles": ["abuse"],
243 "vcardArray": ["vcard", [["email", {}, "text", "registrar-abuse@cloudflare.com"]]]
244 }]
245 }],
246 "links": [
247 {"rel": "self", "href": "https://rdap.verisign.com/com/v1/domain/cloudflare.com"},
248 {"rel": "related", "href": "https://rdap.cloudflare.com/rdap/v1/domain/CLOUDFLARE.COM"}
249 ]
250 }"#;
251
252 const REGISTRAR_DOMAIN: &str = r#"{
255 "entities": [{
256 "roles": ["registrar"],
257 "vcardArray": ["vcard", [["email", {}, "text", "registrar-admin@cloudflare.com"]]],
258 "entities": [{
259 "roles": ["abuse"],
260 "vcardArray": ["vcard", [
261 ["fn", {}, "text", "Cloudflare Registrar Abuse"],
262 ["email", {}, "text", "registrar-abuse@cloudflare.com"]
263 ]]
264 }]
265 }, {
266 "roles": ["registrant"],
267 "vcardArray": ["vcard", [
268 ["fn", {}, "text", "DATA REDACTED"],
269 ["email", {}, "text", "DATA REDACTED"]
270 ]]
271 }]
272 }"#;
273
274 fn parse(json: &str) -> Response {
275 serde_json::from_str(json).unwrap()
276 }
277
278 fn emails_of(response: &Response, scope: Scope) -> Vec<String> {
279 response
280 .abuse_contacts(scope, "rdap.example.net")
281 .iter()
282 .map(|c| c.email.to_string())
283 .collect()
284 }
285
286 #[test]
287 fn finds_an_abuse_entity_nested_under_the_registrant() {
288 assert_eq!(
289 emails_of(&parse(ARIN_IP), Scope::Network),
290 ["network-abuse@google.com"]
291 );
292 }
293
294 #[test]
295 fn finds_an_abuse_entity_at_the_top_level() {
296 assert_eq!(
297 emails_of(&parse(RIPE_IP), Scope::Network),
298 ["abuse@ripe.net"]
299 );
300 }
301
302 #[test]
303 fn ignores_entities_without_the_abuse_role() {
304 let contacts = emails_of(&parse(ARIN_IP), Scope::Network);
305
306 assert!(!contacts.contains(&"arin-contact@google.com".to_owned()));
307 }
308
309 #[test]
310 fn finds_an_abuse_entity_nested_under_the_registrar() {
311 assert_eq!(
312 emails_of(&parse(REGISTRY_DOMAIN), Scope::Registrar),
313 ["registrar-abuse@cloudflare.com"]
314 );
315 }
316
317 #[test]
318 fn returns_a_repeated_abuse_entity_one_time() {
319 let response = parse(
320 r#"{"entities":[
321 {"roles":["abuse"],"vcardArray":["vcard",[["email",{},"text","a@example.com"]]]},
322 {"roles":["registrant"],"entities":[
323 {"roles":["abuse"],"vcardArray":["vcard",[["email",{},"text","a@example.com"]]]}
324 ]}
325 ]}"#,
326 );
327
328 assert_eq!(emails_of(&response, Scope::Network), ["a@example.com"]);
329 }
330
331 #[test]
332 fn a_registry_domain_record_points_at_the_registrar() {
333 assert_eq!(
334 parse(REGISTRY_DOMAIN).related_href(),
335 Some("https://rdap.cloudflare.com/rdap/v1/domain/CLOUDFLARE.COM")
336 );
337 }
338
339 #[test]
340 fn finds_the_registrar_abuse_contact_and_drops_the_redacted_one() {
341 assert_eq!(
342 emails_of(&parse(REGISTRAR_DOMAIN), Scope::Registrar),
343 ["registrar-abuse@cloudflare.com"]
344 );
345 }
346
347 #[test]
348 fn records_which_server_answered() {
349 let contacts = parse(RIPE_IP).abuse_contacts(Scope::Network, "rdap.db.ripe.net");
350
351 assert_eq!(
352 contacts[0].source,
353 Source::Rdap {
354 server: "rdap.db.ripe.net".to_owned()
355 }
356 );
357 }
358
359 #[test]
360 fn reads_the_preferred_address_first() {
361 let response = parse(
363 r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
364 ["email", {}, "text", "helpdesk@apnic.net"],
365 ["email", {"pref": "1"}, "text", "abuse@apnic.net"]
366 ]]}]}"#,
367 );
368
369 assert_eq!(
370 emails_of(&response, Scope::Network),
371 ["abuse@apnic.net", "helpdesk@apnic.net"]
372 );
373 }
374
375 #[test]
376 fn reads_a_numeric_pref() {
377 let response = parse(
378 r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
379 ["email", {}, "text", "second@example.com"],
380 ["email", {"pref": 1}, "text", "first@example.com"]
381 ]]}]}"#,
382 );
383
384 assert_eq!(
385 emails_of(&response, Scope::Network),
386 ["first@example.com", "second@example.com"]
387 );
388 }
389
390 #[test]
391 fn keeps_document_order_when_no_property_names_a_preference() {
392 let response = parse(
393 r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
394 ["email", {}, "text", "one@example.com"],
395 ["email", {}, "text", "two@example.com"]
396 ]]}]}"#,
397 );
398
399 assert_eq!(
400 emails_of(&response, Scope::Network),
401 ["one@example.com", "two@example.com"]
402 );
403 }
404
405 #[test]
406 fn finds_an_entity_that_carries_the_abuse_role_beside_another() {
407 let response = parse(
409 r#"{"entities":[{"roles":["technical","abuse"],"vcardArray":["vcard",[
410 ["email", {}, "text", "noc@example.com"]
411 ]]}]}"#,
412 );
413
414 assert_eq!(emails_of(&response, Scope::Network), ["noc@example.com"]);
415 }
416
417 #[test]
418 fn an_abuse_entity_without_an_address_gives_nothing() {
419 let response = parse(
421 r#"{"entities":[{"roles":["technical","abuse"],"vcardArray":["vcard",[
422 ["fn", {}, "text", "Frederico Augusto de Carvalho Neves"],
423 ["lang", {}, "language-tag", "pt"]
424 ]]}]}"#,
425 );
426
427 assert_eq!(emails_of(&response, Scope::Network), Vec::<String>::new());
428 }
429
430 #[test]
431 fn reads_an_empty_response() {
432 let response = parse("{}");
433
434 assert_eq!(response.abuse_contacts(Scope::Network, "x"), Vec::new());
435 assert_eq!(response.related_href(), None);
436 }
437
438 #[test]
439 fn skips_a_jcard_property_in_a_shape_it_does_not_know() {
440 let response = parse(
441 r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
442 ["email"],
443 ["email", {}, "text", ["abuse@example.com"]],
444 ["email", {}, "text", "good@example.com"]
445 ]]}]}"#,
446 );
447
448 assert_eq!(emails_of(&response, Scope::Network), ["good@example.com"]);
449 }
450
451 #[test]
452 fn reads_the_range_of_a_network() {
453 let response =
454 parse(r#"{"startAddress": "8.8.8.0", "endAddress": "8.8.8.255", "entities": []}"#);
455
456 assert_eq!(
457 response.range(),
458 Some("8.8.8.0".parse().unwrap()..="8.8.8.255".parse().unwrap())
459 );
460 }
461
462 #[test]
463 fn a_range_that_cannot_be_read_is_none() {
464 for json in [
465 r#"{}"#,
467 r#"{"startAddress": "8.8.8.0"}"#,
468 r#"{"startAddress": "8.8.8.0", "endAddress": "not an address"}"#,
469 r#"{"startAddress": "8.8.8.0", "endAddress": "2001:db8::ff"}"#,
471 r#"{"startAddress": "8.8.8.255", "endAddress": "8.8.8.0"}"#,
473 r#"{"startAddress": 1, "endAddress": 2}"#,
475 ] {
476 assert_eq!(parse(json).range(), None, "{json}");
477 }
478 }
479}