use serde::Deserialize;
use crate::contact::{Contact, EmailAddress, Scope, Source};
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Response {
#[serde(default)]
pub entities: Vec<Entity>,
#[serde(default)]
pub links: Vec<Link>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Entity {
#[serde(default)]
pub roles: Vec<String>,
pub handle: Option<String>,
#[serde(rename = "vcardArray")]
pub vcard_array: Option<serde_json::Value>,
#[serde(default)]
pub entities: Vec<Entity>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct Link {
pub rel: Option<String>,
pub href: Option<String>,
}
const ABUSE_ROLE: &str = "abuse";
impl Response {
pub fn related_href(&self) -> Option<&str> {
self.links
.iter()
.find(|link| link.rel.as_deref() == Some("related"))
.and_then(|link| link.href.as_deref())
}
pub fn abuse_contacts(&self, scope: Scope, server: &str) -> Vec<Contact> {
let mut contacts = Vec::new();
collect(&self.entities, scope, server, &mut contacts);
let mut seen = std::collections::HashSet::new();
contacts.retain(|contact| seen.insert(contact.email.clone()));
contacts
}
}
fn collect(entities: &[Entity], scope: Scope, server: &str, out: &mut Vec<Contact>) {
for entity in entities {
if entity.roles.iter().any(|role| role == ABUSE_ROLE) {
out.extend(
emails(entity.vcard_array.as_ref())
.into_iter()
.filter_map(|value| EmailAddress::new(value).ok())
.map(|email| Contact {
email,
scope,
source: Source::Rdap {
server: server.to_owned(),
},
}),
);
}
collect(&entity.entities, scope, server, out);
}
}
const DEFAULT_PREF: u32 = 100;
fn emails(vcard_array: Option<&serde_json::Value>) -> Vec<&str> {
let Some(properties) = vcard_array
.and_then(|v| v.get(1))
.and_then(|v| v.as_array())
else {
return Vec::new();
};
let mut found: Vec<(u32, &str)> = properties
.iter()
.filter_map(|property| property.as_array())
.filter(|property| property.first().and_then(|n| n.as_str()) == Some("email"))
.filter_map(|property| {
let value = property.get(3).and_then(|value| value.as_str())?;
Some((preference(property.get(1)), value))
})
.collect();
found.sort_by_key(|(pref, _)| *pref);
found.into_iter().map(|(_, value)| value).collect()
}
fn preference(parameters: Option<&serde_json::Value>) -> u32 {
let Some(pref) = parameters.and_then(|p| p.get("pref")) else {
return DEFAULT_PREF;
};
if let Some(text) = pref.as_str() {
return text.parse().unwrap_or(DEFAULT_PREF);
}
pref.as_u64()
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(DEFAULT_PREF)
}
#[cfg(test)]
mod tests {
use super::*;
const ARIN_IP: &str = r#"{
"entities": [{
"handle": "GOGL",
"roles": ["registrant"],
"vcardArray": ["vcard", [["version", {}, "text", "4.0"], ["fn", {}, "text", "Google LLC"]]],
"entities": [{
"handle": "ABUSE5250-ARIN",
"roles": ["abuse"],
"vcardArray": ["vcard", [
["fn", {}, "text", "Abuse"],
["email", {}, "text", "network-abuse@google.com"],
["tel", {"type": ["work", "voice"]}, "text", "+1-650-253-0000"]
]]
}, {
"handle": "ZG39-ARIN",
"roles": ["administrative", "technical"],
"vcardArray": ["vcard", [["email", {}, "text", "arin-contact@google.com"]]]
}]
}]
}"#;
const RIPE_IP: &str = r#"{
"entities": [{
"handle": "MDIR-RIPE",
"roles": ["administrative"],
"vcardArray": ["vcard", [["fn", {}, "text", "Managing Director"]]]
}, {
"handle": "OPS4-RIPE",
"roles": ["abuse"],
"vcardArray": ["vcard", [
["fn", {}, "text", "RIPE NCC Operations"],
["email", {}, "text", "abuse@ripe.net"]
]]
}]
}"#;
const REGISTRY_DOMAIN: &str = r#"{
"entities": [{
"roles": ["registrar"],
"vcardArray": ["vcard", [["fn", {}, "text", "Cloudflare, Inc."]]],
"entities": [{
"roles": ["abuse"],
"vcardArray": ["vcard", [["email", {}, "text", "registrar-abuse@cloudflare.com"]]]
}]
}],
"links": [
{"rel": "self", "href": "https://rdap.verisign.com/com/v1/domain/cloudflare.com"},
{"rel": "related", "href": "https://rdap.cloudflare.com/rdap/v1/domain/CLOUDFLARE.COM"}
]
}"#;
const REGISTRAR_DOMAIN: &str = r#"{
"entities": [{
"roles": ["registrar"],
"vcardArray": ["vcard", [["email", {}, "text", "registrar-admin@cloudflare.com"]]],
"entities": [{
"roles": ["abuse"],
"vcardArray": ["vcard", [
["fn", {}, "text", "Cloudflare Registrar Abuse"],
["email", {}, "text", "registrar-abuse@cloudflare.com"]
]]
}]
}, {
"roles": ["registrant"],
"vcardArray": ["vcard", [
["fn", {}, "text", "DATA REDACTED"],
["email", {}, "text", "DATA REDACTED"]
]]
}]
}"#;
fn parse(json: &str) -> Response {
serde_json::from_str(json).unwrap()
}
fn emails_of(response: &Response, scope: Scope) -> Vec<String> {
response
.abuse_contacts(scope, "rdap.example.net")
.iter()
.map(|c| c.email.to_string())
.collect()
}
#[test]
fn finds_an_abuse_entity_nested_under_the_registrant() {
assert_eq!(
emails_of(&parse(ARIN_IP), Scope::Network),
["network-abuse@google.com"]
);
}
#[test]
fn finds_an_abuse_entity_at_the_top_level() {
assert_eq!(
emails_of(&parse(RIPE_IP), Scope::Network),
["abuse@ripe.net"]
);
}
#[test]
fn ignores_entities_without_the_abuse_role() {
let contacts = emails_of(&parse(ARIN_IP), Scope::Network);
assert!(!contacts.contains(&"arin-contact@google.com".to_owned()));
}
#[test]
fn finds_an_abuse_entity_nested_under_the_registrar() {
assert_eq!(
emails_of(&parse(REGISTRY_DOMAIN), Scope::Registrar),
["registrar-abuse@cloudflare.com"]
);
}
#[test]
fn returns_a_repeated_abuse_entity_one_time() {
let response = parse(
r#"{"entities":[
{"roles":["abuse"],"vcardArray":["vcard",[["email",{},"text","a@example.com"]]]},
{"roles":["registrant"],"entities":[
{"roles":["abuse"],"vcardArray":["vcard",[["email",{},"text","a@example.com"]]]}
]}
]}"#,
);
assert_eq!(emails_of(&response, Scope::Network), ["a@example.com"]);
}
#[test]
fn a_registry_domain_record_points_at_the_registrar() {
assert_eq!(
parse(REGISTRY_DOMAIN).related_href(),
Some("https://rdap.cloudflare.com/rdap/v1/domain/CLOUDFLARE.COM")
);
}
#[test]
fn finds_the_registrar_abuse_contact_and_drops_the_redacted_one() {
assert_eq!(
emails_of(&parse(REGISTRAR_DOMAIN), Scope::Registrar),
["registrar-abuse@cloudflare.com"]
);
}
#[test]
fn records_which_server_answered() {
let contacts = parse(RIPE_IP).abuse_contacts(Scope::Network, "rdap.db.ripe.net");
assert_eq!(
contacts[0].source,
Source::Rdap {
server: "rdap.db.ripe.net".to_owned()
}
);
}
#[test]
fn reads_the_preferred_address_first() {
let response = parse(
r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
["email", {}, "text", "helpdesk@apnic.net"],
["email", {"pref": "1"}, "text", "abuse@apnic.net"]
]]}]}"#,
);
assert_eq!(
emails_of(&response, Scope::Network),
["abuse@apnic.net", "helpdesk@apnic.net"]
);
}
#[test]
fn reads_a_numeric_pref() {
let response = parse(
r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
["email", {}, "text", "second@example.com"],
["email", {"pref": 1}, "text", "first@example.com"]
]]}]}"#,
);
assert_eq!(
emails_of(&response, Scope::Network),
["first@example.com", "second@example.com"]
);
}
#[test]
fn keeps_document_order_when_no_property_names_a_preference() {
let response = parse(
r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
["email", {}, "text", "one@example.com"],
["email", {}, "text", "two@example.com"]
]]}]}"#,
);
assert_eq!(
emails_of(&response, Scope::Network),
["one@example.com", "two@example.com"]
);
}
#[test]
fn finds_an_entity_that_carries_the_abuse_role_beside_another() {
let response = parse(
r#"{"entities":[{"roles":["technical","abuse"],"vcardArray":["vcard",[
["email", {}, "text", "noc@example.com"]
]]}]}"#,
);
assert_eq!(emails_of(&response, Scope::Network), ["noc@example.com"]);
}
#[test]
fn an_abuse_entity_without_an_address_gives_nothing() {
let response = parse(
r#"{"entities":[{"roles":["technical","abuse"],"vcardArray":["vcard",[
["fn", {}, "text", "Frederico Augusto de Carvalho Neves"],
["lang", {}, "language-tag", "pt"]
]]}]}"#,
);
assert_eq!(emails_of(&response, Scope::Network), Vec::<String>::new());
}
#[test]
fn reads_an_empty_response() {
let response = parse("{}");
assert_eq!(response.abuse_contacts(Scope::Network, "x"), Vec::new());
assert_eq!(response.related_href(), None);
}
#[test]
fn skips_a_jcard_property_in_a_shape_it_does_not_know() {
let response = parse(
r#"{"entities":[{"roles":["abuse"],"vcardArray":["vcard",[
["email"],
["email", {}, "text", ["abuse@example.com"]],
["email", {}, "text", "good@example.com"]
]]}]}"#,
);
assert_eq!(emails_of(&response, Scope::Network), ["good@example.com"]);
}
}