1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
2
3use chrono::{DateTime, Utc};
5use icann_rdap_client::{
6 md::{string::StringUtil, table::MultiPartTable, MdOptions},
7 rdap::ResponseData,
8 RdapClientError,
9};
10use icann_rdap_common::{
11 check::{traverse_checks, Check, CheckClass, CheckItem, CheckParams, Checks, GetChecks},
12 response::{types::ExtensionId, RdapResponse},
13};
14use reqwest::StatusCode;
15use serde::Serialize;
16use strum_macros::Display;
17
18use super::exec::TestOptions;
19
20#[derive(Debug, Serialize)]
21pub struct TestResults {
22 pub query_url: String,
23 pub dns_data: DnsData,
24 pub start_time: DateTime<Utc>,
25 pub end_time: Option<DateTime<Utc>>,
26 pub service_checks: Vec<CheckItem>,
27 pub test_runs: Vec<TestRun>,
28}
29
30impl TestResults {
31 pub fn new(query_url: String, dns_data: DnsData) -> Self {
32 TestResults {
33 query_url,
34 dns_data,
35 start_time: Utc::now(),
36 end_time: None,
37 service_checks: vec![],
38 test_runs: vec![],
39 }
40 }
41
42 pub fn end(&mut self, options: &TestOptions) {
43 self.end_time = Some(Utc::now());
44
45 if self.dns_data.v4_cname.is_some() && self.dns_data.v4_addrs.is_empty() {
47 self.service_checks
48 .push(Check::CnameWithoutARecords.check_item());
49 }
50 if self.dns_data.v6_cname.is_some() && self.dns_data.v6_addrs.is_empty() {
51 self.service_checks
52 .push(Check::CnameWithoutAAAARecords.check_item());
53 }
54 if self.dns_data.v4_addrs.is_empty() {
55 self.service_checks.push(Check::NoARecords.check_item());
56 }
57 if self.dns_data.v6_addrs.is_empty() {
58 self.service_checks.push(Check::NoAAAARecords.check_item());
59
60 let tig0 = ExtensionId::IcannRdapTechnicalImplementationGuide0.to_string();
62 let tig1 = ExtensionId::IcannRdapTechnicalImplementationGuide1.to_string();
63 let both_tigs = format!("{tig0}|{tig1}");
64 if options.expect_extensions.contains(&tig0)
65 || options.expect_extensions.contains(&tig1)
66 || options.expect_extensions.contains(&both_tigs)
67 {
68 self.service_checks
69 .push(Check::Ipv6SupportRequiredByIcann.check_item())
70 }
71 }
72 }
73
74 pub fn add_test_run(&mut self, test_run: TestRun) {
75 self.test_runs.push(test_run);
76 }
77
78 pub fn to_md(&self, options: &MdOptions, check_classes: &[CheckClass]) -> String {
79 let mut md = String::new();
80
81 md.push_str(&format!(
83 "\n{}\n",
84 self.query_url.to_owned().to_header(1, options)
85 ));
86
87 let mut table = MultiPartTable::new();
89
90 table = table.multi(vec![
92 "Start Time".to_inline(options),
93 "End Time".to_inline(options),
94 "Duration".to_inline(options),
95 "Tested".to_inline(options),
96 ]);
97 let (end_time_s, duration_s) = if let Some(end_time) = self.end_time {
98 (
99 format_date_time(end_time),
100 format!("{} s", (end_time - self.start_time).num_seconds()),
101 )
102 } else {
103 ("FATAL".to_em(options), "N/A".to_string())
104 };
105 let tested = self
106 .test_runs
107 .iter()
108 .filter(|r| matches!(r.outcome, RunOutcome::Tested))
109 .count();
110 table = table.multi(vec![
111 format_date_time(self.start_time),
112 end_time_s,
113 duration_s,
114 format!("{tested} of {}", self.test_runs.len()),
115 ]);
116
117 table = table.multi(vec![
119 "DNS Query".to_inline(options),
120 "DNS Answer".to_inline(options),
121 ]);
122 let v4_cname = if let Some(ref cname) = self.dns_data.v4_cname {
123 cname.to_owned()
124 } else {
125 format!("{} A records", self.dns_data.v4_addrs.len())
126 };
127 table = table.multi(vec!["A (v4)".to_string(), v4_cname]);
128 let v6_cname = if let Some(ref cname) = self.dns_data.v6_cname {
129 cname.to_owned()
130 } else {
131 format!("{} AAAA records", self.dns_data.v6_addrs.len())
132 };
133 table = table.multi(vec!["AAAA (v6)".to_string(), v6_cname]);
134
135 table = table.multi(vec![
137 "Address".to_inline(options),
138 "Attributes".to_inline(options),
139 "Duration".to_inline(options),
140 "Outcome".to_inline(options),
141 ]);
142 for test_run in &self.test_runs {
143 table = test_run.add_summary(table, options);
144 }
145 md.push_str(&table.to_md_table(options));
146
147 md.push('\n');
148
149 if !self.service_checks.is_empty() {
151 md.push_str(&"Service Checks".to_string().to_header(1, options));
152 let mut table = MultiPartTable::new();
153
154 table = table.multi(vec!["Message".to_inline(options)]);
155 for c in &self.service_checks {
156 let message = check_item_md(c, options);
157 table = table.multi(vec![message]);
158 }
159 md.push_str(&table.to_md_table(options));
160 md.push('\n');
161 }
162
163 for run in &self.test_runs {
165 md.push_str(&run.to_md(options, check_classes));
166 }
167 md
168 }
169}
170
171#[derive(Debug, Serialize, Clone, Default)]
172pub struct DnsData {
173 pub v4_cname: Option<String>,
174 pub v6_cname: Option<String>,
175 pub v4_addrs: Vec<Ipv4Addr>,
176 pub v6_addrs: Vec<Ipv6Addr>,
177}
178
179#[derive(Debug, Serialize, Display)]
180#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
181pub enum RunOutcome {
182 Tested,
183 NetworkError,
184 HttpProtocolError,
185 HttpConnectError,
186 HttpRedirectError,
187 HttpTimeoutError,
188 HttpNon200Error,
189 HttpTooManyRequestsError,
190 HttpNotFoundError,
191 HttpBadRequestError,
192 HttpUnauthorizedError,
193 HttpForbiddenError,
194 JsonError,
195 RdapDataError,
196 InternalError,
197 Skipped,
198}
199
200#[derive(Debug, Serialize, Display)]
201#[strum(serialize_all = "snake_case")]
202pub enum RunFeature {
203 OriginHeader,
204}
205
206impl RunOutcome {
207 pub fn to_md(&self, options: &MdOptions) -> String {
208 match self {
209 RunOutcome::Tested => self.to_bold(options),
210 RunOutcome::Skipped => self.to_string(),
211 _ => self.to_em(options),
212 }
213 }
214}
215
216#[derive(Debug, Serialize)]
217pub struct TestRun {
218 pub features: Vec<RunFeature>,
219 pub socket_addr: SocketAddr,
220 pub start_time: DateTime<Utc>,
221 pub end_time: Option<DateTime<Utc>>,
222 pub response_data: Option<ResponseData>,
223 pub outcome: RunOutcome,
224 pub checks: Option<Checks>,
225}
226
227impl TestRun {
228 pub fn new_v4(features: Vec<RunFeature>, ipv4: Ipv4Addr, port: u16) -> Self {
229 TestRun {
230 features,
231 start_time: Utc::now(),
232 socket_addr: SocketAddr::new(IpAddr::V4(ipv4), port),
233 end_time: None,
234 response_data: None,
235 outcome: RunOutcome::Skipped,
236 checks: None,
237 }
238 }
239
240 pub fn new_v6(features: Vec<RunFeature>, ipv6: Ipv6Addr, port: u16) -> Self {
241 TestRun {
242 features,
243 start_time: Utc::now(),
244 socket_addr: SocketAddr::new(IpAddr::V6(ipv6), port),
245 end_time: None,
246 response_data: None,
247 outcome: RunOutcome::Skipped,
248 checks: None,
249 }
250 }
251
252 pub fn end(
253 mut self,
254 rdap_response: Result<ResponseData, RdapClientError>,
255 options: &TestOptions,
256 ) -> Self {
257 if let Ok(response_data) = rdap_response {
258 self.end_time = Some(Utc::now());
259 self.outcome = RunOutcome::Tested;
260 self.checks = Some(do_checks(&response_data, options));
261 self.response_data = Some(response_data);
262 } else {
263 self.outcome = match rdap_response.err().unwrap() {
264 RdapClientError::InvalidQueryValue
265 | RdapClientError::AmbiquousQueryType
266 | RdapClientError::Poison
267 | RdapClientError::DomainNameError(_)
268 | RdapClientError::BootstrapUnavailable
269 | RdapClientError::BootstrapError(_)
270 | RdapClientError::IanaResponse(_) => RunOutcome::InternalError,
271 RdapClientError::Response(_) => RunOutcome::RdapDataError,
272 RdapClientError::Json(_) | RdapClientError::ParsingError(_) => {
273 RunOutcome::JsonError
274 }
275 RdapClientError::IoError(_) => RunOutcome::NetworkError,
276 RdapClientError::Client(e) => {
277 if e.is_redirect() {
278 RunOutcome::HttpRedirectError
279 } else if e.is_connect() {
280 RunOutcome::HttpConnectError
281 } else if e.is_timeout() {
282 RunOutcome::HttpTimeoutError
283 } else if e.is_status() {
284 match e.status().unwrap() {
285 StatusCode::TOO_MANY_REQUESTS => RunOutcome::HttpTooManyRequestsError,
286 StatusCode::NOT_FOUND => RunOutcome::HttpNotFoundError,
287 StatusCode::BAD_REQUEST => RunOutcome::HttpBadRequestError,
288 StatusCode::UNAUTHORIZED => RunOutcome::HttpUnauthorizedError,
289 StatusCode::FORBIDDEN => RunOutcome::HttpForbiddenError,
290 _ => RunOutcome::HttpNon200Error,
291 }
292 } else {
293 RunOutcome::HttpProtocolError
294 }
295 }
296 };
297 self.end_time = Some(Utc::now());
298 };
299 self
300 }
301
302 fn add_summary(&self, mut table: MultiPartTable, options: &MdOptions) -> MultiPartTable {
303 let duration_s = if let Some(end_time) = self.end_time {
304 format!("{} ms", (end_time - self.start_time).num_milliseconds())
305 } else {
306 "n/a".to_string()
307 };
308 table = table.multi(vec![
309 self.socket_addr.to_string(),
310 self.attribute_set(),
311 duration_s,
312 self.outcome.to_md(options),
313 ]);
314 table
315 }
316
317 fn to_md(&self, options: &MdOptions, check_classes: &[CheckClass]) -> String {
318 let mut md = String::new();
319
320 let header_value = format!("{} - {}", self.socket_addr, self.attribute_set());
322 md.push_str(&format!("\n{}\n", header_value.to_header(1, options)));
323
324 if matches!(self.outcome, RunOutcome::Tested) {
326 let mut check_v: Vec<(String, String)> = Vec::new();
328 if let Some(ref checks) = self.checks {
329 traverse_checks(checks, check_classes, None, &mut |struct_name, item| {
330 let message = check_item_md(item, options);
331 check_v.push((struct_name.to_string(), message))
332 });
333 };
334
335 let mut table = MultiPartTable::new();
337
338 if check_v.is_empty() {
339 table = table.header_ref(&"No issues or errors.");
340 } else {
341 table = table.multi(vec![
342 "RDAP Structure".to_inline(options),
343 "Message".to_inline(options),
344 ]);
345 for c in check_v {
346 table = table.nv(&c.0, c.1);
347 }
348 }
349 md.push_str(&table.to_md_table(options));
350 } else {
351 let mut table = MultiPartTable::new();
352 table = table.multi(vec![self.outcome.to_md(options)]);
353 md.push_str(&table.to_md_table(options));
354 }
355
356 md
357 }
358
359 fn attribute_set(&self) -> String {
360 let socket_type = if self.socket_addr.is_ipv4() {
361 "v4"
362 } else {
363 "v6"
364 };
365 if !self.features.is_empty() {
366 format!(
367 "{socket_type}, {}",
368 self.features
369 .iter()
370 .map(|f| f.to_string())
371 .collect::<Vec<_>>()
372 .join(", ")
373 )
374 } else {
375 socket_type.to_string()
376 }
377 }
378}
379
380fn check_item_md(item: &CheckItem, options: &MdOptions) -> String {
381 if !matches!(item.check_class, CheckClass::Informational)
382 && !matches!(item.check_class, CheckClass::SpecificationNote)
383 {
384 item.to_string().to_em(options)
385 } else {
386 item.to_string()
387 }
388}
389
390fn format_date_time(date: DateTime<Utc>) -> String {
391 date.format("%a, %v %X %Z").to_string()
392}
393
394fn do_checks(response: &ResponseData, options: &TestOptions) -> Checks {
395 let check_params = CheckParams {
396 do_subchecks: true,
397 root: &response.rdap,
398 parent_type: response.rdap.get_type(),
399 allow_unreg_ext: options.allow_unregistered_extensions,
400 };
401 let mut checks = response.rdap.get_checks(check_params);
402
403 checks
405 .items
406 .append(&mut response.http_data.get_checks(check_params).items);
407
408 for ext in &options.expect_extensions {
410 if !rdap_has_expected_extension(&response.rdap, ext) {
411 checks
412 .items
413 .push(Check::ExpectedExtensionNotFound.check_item());
414 }
415 }
416
417 checks
419}
420
421fn rdap_has_expected_extension(rdap: &RdapResponse, ext: &str) -> bool {
422 let count = ext.split('|').filter(|s| rdap.has_extension(s)).count();
423 count > 0
424}
425
426#[cfg(test)]
427#[allow(non_snake_case)]
428mod tests {
429 use icann_rdap_common::response::{
430 domain::Domain,
431 types::{Common, Extension},
432 RdapResponse,
433 };
434
435 use super::rdap_has_expected_extension;
436
437 #[test]
438 fn GIVEN_expected_extension_WHEN_rdap_has_THEN_true() {
439 let domain = Domain::basic().ldh_name("foo.example.com").build();
441 let domain = Domain {
442 common: Common::level0_with_options()
443 .extension(Extension::from("foo0"))
444 .build(),
445 ..domain
446 };
447 let rdap = RdapResponse::Domain(domain);
448
449 let actual = rdap_has_expected_extension(&rdap, "foo0");
451
452 assert!(actual);
454 }
455
456 #[test]
457 fn GIVEN_expected_extension_WHEN_rdap_does_not_have_THEN_false() {
458 let domain = Domain::basic().ldh_name("foo.example.com").build();
460 let domain = Domain {
461 common: Common::level0_with_options()
462 .extension(Extension::from("foo0"))
463 .build(),
464 ..domain
465 };
466 let rdap = RdapResponse::Domain(domain);
467
468 let actual = rdap_has_expected_extension(&rdap, "foo1");
470
471 assert!(!actual);
473 }
474
475 #[test]
476 fn GIVEN_compound_expected_extension_WHEN_rdap_has_THEN_true() {
477 let domain = Domain::basic().ldh_name("foo.example.com").build();
479 let domain = Domain {
480 common: Common::level0_with_options()
481 .extension(Extension::from("foo0"))
482 .build(),
483 ..domain
484 };
485 let rdap = RdapResponse::Domain(domain);
486
487 let actual = rdap_has_expected_extension(&rdap, "foo0|foo1");
489
490 assert!(actual);
492 }
493}