Skip to main content

icann_rdap_cli/rt/
results.rs

1use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
2
3/// Contains the results of test execution.
4use chrono::{DateTime, Utc};
5use icann_rdap_common::check::{
6    process::{do_check_processing, get_summaries},
7    CheckSummary,
8};
9use {
10    icann_rdap_client::{
11        md::{string::StringUtil, table::MultiPartTable, MdOptions},
12        rdap::ResponseData,
13        RdapClientError,
14    },
15    icann_rdap_common::{
16        check::{Check, CheckClass, CheckItem, Checks},
17        response::ExtensionId,
18    },
19    reqwest::StatusCode,
20    serde::Serialize,
21    strum_macros::Display,
22};
23
24use super::exec::TestOptions;
25
26#[derive(Debug, Serialize, Clone)]
27pub enum TestResults {
28    Http(HttpResults),
29    String(Box<StringResult>),
30}
31
32impl TestResults {
33    pub fn to_md(&self, options: &MdOptions) -> String {
34        match self {
35            TestResults::Http(http_results) => http_results.to_md(options),
36            TestResults::String(string_result) => string_result.to_md(options),
37        }
38    }
39
40    pub fn execution_errors(&self) -> bool {
41        match self {
42            TestResults::Http(http_results) => http_results.execution_errors(),
43            TestResults::String(string_result) => string_result.execution_errors(),
44        }
45    }
46
47    pub fn are_there_checks(&self, classes: Vec<CheckClass>) -> bool {
48        match self {
49            TestResults::Http(http_results) => http_results.are_there_checks(classes),
50            TestResults::String(string_result) => string_result.are_there_checks(classes),
51        }
52    }
53
54    pub fn filter_test_results(&self, classes: Vec<CheckClass>) -> TestResults {
55        match self {
56            TestResults::Http(http_results) => {
57                TestResults::Http(http_results.clone().filter_test_results(&classes))
58            }
59            TestResults::String(string_result) => TestResults::String(Box::new(
60                string_result.clone().filter_test_results(&classes),
61            )),
62        }
63    }
64}
65
66#[derive(Debug, Serialize, Clone)]
67pub struct HttpResults {
68    pub query_url: String,
69    pub start_time: DateTime<Utc>,
70    pub end_time: Option<DateTime<Utc>>,
71    pub dns_data: DnsData,
72    pub service_checks: Vec<CheckItem>,
73    pub test_runs: Vec<TestRun>,
74}
75
76#[derive(Debug, Serialize, Clone)]
77pub struct StringResult {
78    pub test_run: Option<TestRun>,
79}
80
81impl HttpResults {
82    pub fn new(query_url: String, dns_data: DnsData) -> Self {
83        Self {
84            query_url,
85            dns_data,
86            service_checks: vec![],
87            test_runs: vec![],
88            start_time: Utc::now(),
89            end_time: None,
90        }
91    }
92
93    pub fn end(&mut self, options: &TestOptions) {
94        self.end_time = Some(Utc::now());
95
96        //service checks
97        if self.dns_data.v4_cname.is_some() && self.dns_data.v4_addrs.is_empty() {
98            self.service_checks
99                .push(Check::CnameWithoutARecords.check_item());
100        }
101        if self.dns_data.v6_cname.is_some() && self.dns_data.v6_addrs.is_empty() {
102            self.service_checks
103                .push(Check::CnameWithoutAAAARecords.check_item());
104        }
105        if self.dns_data.v4_addrs.is_empty() {
106            self.service_checks.push(Check::NoARecords.check_item());
107        }
108        if self.dns_data.v6_addrs.is_empty() {
109            self.service_checks.push(Check::NoAAAARecords.check_item());
110
111            // see if required by Gtld Profile
112            let tig0 = ExtensionId::IcannRdapTechnicalImplementationGuide0.to_string();
113            let tig1 = ExtensionId::IcannRdapTechnicalImplementationGuide1.to_string();
114            let both_tigs = format!("{tig0}|{tig1}");
115            if options.expect_extensions.contains(&tig0)
116                || options.expect_extensions.contains(&tig1)
117                || options.expect_extensions.contains(&both_tigs)
118            {
119                self.service_checks
120                    .push(Check::Ipv6SupportRequiredByGtldProfile.check_item())
121            }
122        }
123    }
124
125    pub fn add_test_run(&mut self, test_run: TestRun) {
126        self.test_runs.push(test_run);
127    }
128
129    pub fn to_md(&self, options: &MdOptions) -> String {
130        let mut md = String::new();
131
132        // h1
133        md.push_str(&format!(
134            "\n{}\n",
135            self.query_url.to_owned().to_header(1, options)
136        ));
137
138        // table
139        let mut table = MultiPartTable::new();
140
141        // test results summary
142        table = table.multi_raw(vec![
143            "Start Time".to_inline(options),
144            "End Time".to_inline(options),
145            "Duration".to_inline(options),
146            "Tested".to_inline(options),
147        ]);
148        let (end_time_s, duration_s) = if let Some(end_time) = self.end_time {
149            (
150                format_date_time(end_time),
151                format!("{} s", (end_time - self.start_time).num_seconds()),
152            )
153        } else {
154            ("FATAL".to_em(options), "N/A".to_string())
155        };
156        let tested = self
157            .test_runs
158            .iter()
159            .filter(|r| matches!(r.outcome, RunOutcome::Tested))
160            .count();
161        table = table.multi_raw(vec![
162            format_date_time(self.start_time),
163            end_time_s,
164            duration_s,
165            format!("{tested} of {}", self.test_runs.len()),
166        ]);
167
168        // dns data
169        table = table.multi_raw(vec![
170            "DNS Query".to_inline(options),
171            "DNS Answer".to_inline(options),
172        ]);
173        let v4_cname = if let Some(ref cname) = self.dns_data.v4_cname {
174            cname.to_owned()
175        } else {
176            format!("{} A records", self.dns_data.v4_addrs.len())
177        };
178        table = table.multi_raw(vec!["A (v4)".to_string(), v4_cname]);
179        let v6_cname = if let Some(ref cname) = self.dns_data.v6_cname {
180            cname.to_owned()
181        } else {
182            format!("{} AAAA records", self.dns_data.v6_addrs.len())
183        };
184        table = table.multi_raw(vec!["AAAA (v6)".to_string(), v6_cname]);
185
186        // summary of each run
187        table = table.multi_raw(vec![
188            "Address".to_inline(options),
189            "Attributes".to_inline(options),
190            "Duration".to_inline(options),
191            "Outcome".to_inline(options),
192        ]);
193        for test_run in &self.test_runs {
194            table = test_run.add_summary(table, options);
195        }
196        md.push_str(&table.to_md_table(options));
197
198        md.push('\n');
199
200        // checks that are about the service and not a particular test run
201        if !self.service_checks.is_empty() {
202            md.push_str(&"Service Checks".to_string().to_header(1, options));
203            let mut table = MultiPartTable::new();
204
205            table = table.multi_raw(vec!["Message".to_inline(options)]);
206            for c in &self.service_checks {
207                let message = check_item_md(c, options);
208                table = table.multi_raw(vec![message]);
209            }
210            md.push_str(&table.to_md_table(options));
211            md.push('\n');
212        }
213
214        // each run in detail
215        for run in &self.test_runs {
216            md.push_str(&run.to_md(options));
217        }
218        md
219    }
220
221    pub fn execution_errors(&self) -> bool {
222        self.test_runs
223            .iter()
224            .filter(|r| !matches!(r.outcome, RunOutcome::Tested | RunOutcome::Skipped))
225            .count()
226            != 0
227    }
228
229    pub fn are_there_checks(&self, classes: Vec<CheckClass>) -> bool {
230        // see if there are any checks in the test runs
231        let run_count = self
232            .test_runs
233            .iter()
234            .filter(|r| {
235                r.summaries
236                    .as_deref()
237                    .unwrap_or_default()
238                    .iter()
239                    .any(|s| classes.contains(&s.item.check_class))
240            })
241            .count();
242        // see if there are any classes in the service checks
243        let service_count = self
244            .service_checks
245            .iter()
246            .filter(|c| classes.contains(&c.check_class))
247            .count();
248        run_count + service_count != 0
249    }
250
251    pub fn filter_test_results(self, classes: &[CheckClass]) -> Self {
252        // filter service checks
253        let filtered_service_checks: Vec<CheckItem> = self
254            .service_checks
255            .into_iter()
256            .filter(|c| classes.contains(&c.check_class))
257            .collect();
258
259        // filter test runs
260        let mut filtered_test_runs = vec![];
261        for mut test_run in self.test_runs {
262            let filtered_summary: Vec<CheckSummary> = test_run
263                .summaries
264                .unwrap_or_default()
265                .into_iter()
266                .filter(|s| classes.contains(&s.item.check_class))
267                .collect();
268            test_run.summaries = Some(filtered_summary);
269            filtered_test_runs.push(test_run);
270        }
271
272        // return
273        Self {
274            service_checks: filtered_service_checks,
275            test_runs: filtered_test_runs,
276            ..self
277        }
278    }
279}
280
281impl StringResult {
282    pub fn new(test_run: TestRun) -> Self {
283        Self {
284            test_run: Some(test_run),
285        }
286    }
287
288    pub fn to_md(&self, options: &MdOptions) -> String {
289        let mut md = String::new();
290
291        if let Some(test_run) = &self.test_run {
292            md.push_str(&test_run.to_md(options));
293        }
294        md
295    }
296
297    pub fn execution_errors(&self) -> bool {
298        self.test_run
299            .iter()
300            .filter(|r| !matches!(r.outcome, RunOutcome::Tested | RunOutcome::Skipped))
301            .count()
302            != 0
303    }
304
305    pub fn are_there_checks(&self, classes: Vec<CheckClass>) -> bool {
306        // see if there are any checks in the test runs
307        let run_count = self
308            .test_run
309            .iter()
310            .filter(|r| {
311                r.summaries
312                    .as_deref()
313                    .unwrap_or_default()
314                    .iter()
315                    .any(|s| classes.contains(&s.item.check_class))
316            })
317            .count();
318        run_count != 0
319    }
320
321    pub fn filter_test_results(self, classes: &[CheckClass]) -> Self {
322        // filter test runs
323        let mut filtered_test_run = None;
324        if let Some(mut test_run) = self.test_run {
325            let filtered_summary: Vec<CheckSummary> = test_run
326                .summaries
327                .unwrap_or_default()
328                .into_iter()
329                .filter(|s| classes.contains(&s.item.check_class))
330                .collect();
331            test_run.summaries = Some(filtered_summary);
332            filtered_test_run = Some(test_run);
333        }
334        // return
335        Self {
336            test_run: filtered_test_run,
337        }
338    }
339}
340
341#[derive(Debug, Serialize, Clone, Default)]
342pub struct DnsData {
343    pub v4_cname: Option<String>,
344    pub v6_cname: Option<String>,
345    pub v4_addrs: Vec<Ipv4Addr>,
346    pub v6_addrs: Vec<Ipv6Addr>,
347}
348
349#[derive(Debug, Serialize, Display, Clone)]
350#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
351pub enum RunOutcome {
352    Tested,
353    NetworkError,
354    HttpProtocolError,
355    HttpConnectError,
356    HttpRedirectResponse,
357    HttpTimeoutError,
358    HttpNon200Error,
359    HttpTooManyRequestsError,
360    HttpNotFoundError,
361    HttpBadRequestError,
362    HttpUnauthorizedError,
363    HttpForbiddenError,
364    JsonError,
365    RdapDataError,
366    InternalError,
367    Skipped,
368}
369
370#[derive(Debug, Serialize, Display, Clone)]
371#[strum(serialize_all = "snake_case")]
372pub enum RunFeature {
373    OriginHeader,
374    ExtsList,
375}
376
377impl RunOutcome {
378    pub fn to_md(&self, options: &MdOptions) -> String {
379        match self {
380            Self::Tested => self.to_bold(options),
381            Self::Skipped => self.to_string(),
382            _ => self.to_em(options),
383        }
384    }
385}
386
387#[derive(Debug, Serialize, Clone)]
388pub struct TestRun {
389    pub features: Vec<RunFeature>,
390    pub socket_addr: Option<SocketAddr>,
391    pub start_time: DateTime<Utc>,
392    pub end_time: Option<DateTime<Utc>>,
393    pub response_data: Option<ResponseData>,
394    pub outcome: RunOutcome,
395    pub summaries: Option<Vec<CheckSummary>>,
396}
397
398impl TestRun {
399    pub fn new(features: Vec<RunFeature>) -> Self {
400        Self {
401            features,
402            start_time: Utc::now(),
403            socket_addr: None,
404            end_time: None,
405            response_data: None,
406            outcome: RunOutcome::Skipped,
407            summaries: None,
408        }
409    }
410
411    pub fn new_ip(features: Vec<RunFeature>, socket_addr: SocketAddr) -> Self {
412        Self {
413            features,
414            start_time: Utc::now(),
415            socket_addr: Some(socket_addr),
416            end_time: None,
417            response_data: None,
418            outcome: RunOutcome::Skipped,
419            summaries: None,
420        }
421    }
422
423    pub fn new_v4(features: Vec<RunFeature>, ipv4: Ipv4Addr, port: u16) -> Self {
424        Self::new_ip(features, SocketAddr::new(IpAddr::V4(ipv4), port))
425    }
426
427    pub fn new_v6(features: Vec<RunFeature>, ipv6: Ipv6Addr, port: u16) -> Self {
428        Self::new_ip(features, SocketAddr::new(IpAddr::V6(ipv6), port))
429    }
430
431    pub fn end(
432        mut self,
433        rdap_response: Result<ResponseData, RdapClientError>,
434        options: &TestOptions,
435    ) -> Self {
436        if let Ok(response_data) = rdap_response {
437            self.end_time = Some(Utc::now());
438            self.outcome = RunOutcome::Tested;
439            self.summaries = Some(get_summaries(&do_checks(&response_data, options), None));
440            self.response_data = Some(response_data);
441        } else {
442            self.outcome = match rdap_response.err().unwrap() {
443                RdapClientError::InvalidQueryValue
444                | RdapClientError::AmbiguousQueryType
445                | RdapClientError::Poison
446                | RdapClientError::DomainNameError(_)
447                | RdapClientError::BootstrapUnavailable
448                | RdapClientError::BootstrapError(_)
449                | RdapClientError::IanaResponse(_) => RunOutcome::InternalError,
450                RdapClientError::Response(_) => RunOutcome::RdapDataError,
451                RdapClientError::Json(_) => RunOutcome::JsonError,
452                RdapClientError::ParsingError(e) => {
453                    let status_code = e.http_data.status_code();
454                    if status_code > 299 && status_code < 400 {
455                        RunOutcome::HttpRedirectResponse
456                    } else {
457                        RunOutcome::JsonError
458                    }
459                }
460                RdapClientError::IoError(_) => RunOutcome::NetworkError,
461                RdapClientError::Client(e) => {
462                    if e.is_redirect() {
463                        RunOutcome::HttpRedirectResponse
464                    } else if e.is_connect() {
465                        RunOutcome::HttpConnectError
466                    } else if e.is_timeout() {
467                        RunOutcome::HttpTimeoutError
468                    } else if e.is_status() {
469                        match e.status().unwrap() {
470                            StatusCode::TOO_MANY_REQUESTS => RunOutcome::HttpTooManyRequestsError,
471                            StatusCode::NOT_FOUND => RunOutcome::HttpNotFoundError,
472                            StatusCode::BAD_REQUEST => RunOutcome::HttpBadRequestError,
473                            StatusCode::UNAUTHORIZED => RunOutcome::HttpUnauthorizedError,
474                            StatusCode::FORBIDDEN => RunOutcome::HttpForbiddenError,
475                            _ => RunOutcome::HttpNon200Error,
476                        }
477                    } else {
478                        RunOutcome::HttpProtocolError
479                    }
480                }
481            };
482            self.end_time = Some(Utc::now());
483        };
484        self
485    }
486
487    fn add_summary(&self, mut table: MultiPartTable, options: &MdOptions) -> MultiPartTable {
488        let duration_s = if let Some(end_time) = self.end_time {
489            format!("{} ms", (end_time - self.start_time).num_milliseconds())
490        } else {
491            "n/a".to_string()
492        };
493        table = table.multi_raw(vec![
494            socket_addr_string(self.socket_addr),
495            self.attribute_set(),
496            duration_s,
497            self.outcome.to_md(options),
498        ]);
499        table
500    }
501
502    fn to_md(&self, options: &MdOptions) -> String {
503        let mut md = String::new();
504
505        // h1
506        let header_value = format!(
507            "{} - {}",
508            socket_addr_string(self.socket_addr),
509            self.attribute_set()
510        );
511        md.push_str(&format!("\n{}\n", header_value.to_header(1, options)));
512
513        // if outcome is tested
514        if matches!(self.outcome, RunOutcome::Tested) {
515            // get check items according to class
516            let mut check_v: Vec<(String, String)> = vec![];
517            for summary in self.summaries.as_deref().unwrap_or_default() {
518                let message = check_item_md(&summary.item, options);
519                check_v.push((summary.structure.to_string(), message));
520            }
521
522            // table
523            let mut table = MultiPartTable::new();
524
525            if check_v.is_empty() {
526                table = table.header_ref(&"No issues or errors.");
527            } else {
528                table = table.multi_raw(vec![
529                    "RDAP Structure".to_inline(options),
530                    "Message".to_inline(options),
531                ]);
532                for c in check_v {
533                    table = table.nv_raw(&c.0, c.1);
534                }
535            }
536            md.push_str(&table.to_md_table(options));
537        } else {
538            let mut table = MultiPartTable::new();
539            table = table.multi_raw(vec![self.outcome.to_md(options)]);
540            md.push_str(&table.to_md_table(options));
541        }
542
543        md
544    }
545
546    fn attribute_set(&self) -> String {
547        let socket_type = socket_type_string(self.socket_addr);
548        if !self.features.is_empty() {
549            format!(
550                "{socket_type}, {}",
551                self.features
552                    .iter()
553                    .map(|f| f.to_string())
554                    .collect::<Vec<_>>()
555                    .join(", ")
556            )
557        } else {
558            socket_type.to_string()
559        }
560    }
561}
562
563fn socket_type_string(sock: Option<SocketAddr>) -> String {
564    if let Some(sock) = sock {
565        if sock.is_ipv4() {
566            "v4".to_string()
567        } else {
568            "v6".to_string()
569        }
570    } else {
571        "file".to_string()
572    }
573}
574
575fn socket_addr_string(sock: Option<SocketAddr>) -> String {
576    if let Some(sock) = sock {
577        sock.to_string()
578    } else {
579        "localhost".to_string()
580    }
581}
582
583fn check_item_md(item: &CheckItem, options: &MdOptions) -> String {
584    if !matches!(item.check_class, CheckClass::Informational)
585        && !matches!(item.check_class, CheckClass::SpecificationNote)
586    {
587        item.to_string().to_em(options)
588    } else {
589        item.to_string()
590    }
591}
592
593fn format_date_time(date: DateTime<Utc>) -> String {
594    date.format("%a, %v %X %Z").to_string()
595}
596
597fn do_checks(response: &ResponseData, options: &TestOptions) -> Checks {
598    let http_data = if matches!(options.test_type, super::exec::TestType::Http(_)) {
599        Some(&response.http_data)
600    } else {
601        None
602    };
603    do_check_processing(
604        &response.rdap,
605        http_data,
606        Some(&options.expect_extensions),
607        options.allow_unregistered_extensions,
608    )
609}