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