diff --git a/Cargo.lock b/Cargo.lock index 724300f..de5544a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,19 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ab-radix-trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1996a0a7d153953579280bca89e620b9bc0a609727984e53f1f4073776746866" +dependencies = [ + "env_logger", + "log", + "rand", + "serde", + "serde_json", +] + [[package]] name = "addr2line" version = "0.21.0" @@ -803,6 +816,19 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "envmnt" version = "0.10.4" @@ -1254,6 +1280,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.27" @@ -1356,7 +1388,7 @@ dependencies = [ [[package]] name = "icann-rdap-cli" -version = "0.0.17" +version = "0.0.18" dependencies = [ "anyhow", "assert_cmd", @@ -1389,7 +1421,7 @@ dependencies = [ [[package]] name = "icann-rdap-client" -version = "0.0.17" +version = "0.0.18" dependencies = [ "buildstructor", "chrono", @@ -1414,7 +1446,7 @@ dependencies = [ [[package]] name = "icann-rdap-common" -version = "0.0.17" +version = "0.0.18" dependencies = [ "buildstructor", "chrono", @@ -1435,8 +1467,9 @@ dependencies = [ [[package]] name = "icann-rdap-srv" -version = "0.0.17" +version = "0.0.18" dependencies = [ + "ab-radix-trie", "assert_cmd", "async-trait", "axum", @@ -1517,6 +1550,17 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "itertools" version = "0.11.0" @@ -3086,6 +3130,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termimad" version = "0.26.1" @@ -3621,6 +3674,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 96deeca..b945bc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.0.17" +version = "0.0.18" edition = "2021" license = "MIT OR Apache-2.0" repository = "https://github.com/icann/icann-rdap" @@ -16,6 +16,9 @@ keywords = ["whois", "rdap"] [workspace.dependencies] +# for suffix string searchs +ab-radix-trie = "0.2.1" + # easy error handling anyhow = "1.0" diff --git a/README.md b/README.md index ba553e2..4098984 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,17 @@ This repository contains open source code written by the Internet Corporation fo for use with the Registry Data Access Protocol (RDAP). RDAP is standard of the [IETF](https://ietf.org/), and extensions to RDAP are a current work activity of the IETF's [REGEXT working group](https://datatracker.ietf.org/wg/regext/documents/). More information on ICANN's role in RDAP can be found [here](https://www.icann.org/rdap). +General information on RDAP can be found [here](https://rdap.rcode3.com/). About ----- -This repository hosts 4 separate Rust crates: +This repository hosts 4 separate packages (i.e. Rust crates): -* [icann-rdap-cli](icann-rdap-cli/README.md) is the Command Line Interface client. +* [icann-rdap-cli](icann-rdap-cli/README.md) is the Command Line Interface client. This package produces an executable binary. * [icann-rdap-client](icann-rdap-client/README.md) is a library handling making RDAP requests. * [icann-rdap-common](icann-rdap-common/README.md) is a library of RDAP structures. -* [icann-rdap-srv](icann-rdap-srv/README.md) is a simple, in-memory RDAP server. +* [icann-rdap-srv](icann-rdap-srv/README.md) is a simple, in-memory RDAP server. This package produces multiple executable binaries. License ------- diff --git a/icann-rdap-cli/Cargo.toml b/icann-rdap-cli/Cargo.toml index aa8e1e6..211c61a 100644 --- a/icann-rdap-cli/Cargo.toml +++ b/icann-rdap-cli/Cargo.toml @@ -14,8 +14,8 @@ path = "src/main.rs" [dependencies] -icann-rdap-client = { version = "0.0.17", path = "../icann-rdap-client" } -icann-rdap-common = { version = "0.0.17", path = "../icann-rdap-common" } +icann-rdap-client = { version = "0.0.18", path = "../icann-rdap-client" } +icann-rdap-common = { version = "0.0.18", path = "../icann-rdap-common" } anyhow.workspace = true clap.workspace = true diff --git a/icann-rdap-cli/README.md b/icann-rdap-cli/README.md index c7b9cb5..de14785 100644 --- a/icann-rdap-cli/README.md +++ b/icann-rdap-cli/README.md @@ -6,6 +6,7 @@ by the Internet Corporation for Assigned Names and Numbers [(ICANN)](https://www RDAP is standard of the [IETF](https://ietf.org/), and extensions to RDAP are a current work activity of the IETF's [REGEXT working group](https://datatracker.ietf.org/wg/regext/documents/). More information on ICANN's role in RDAP can be found [here](https://www.icann.org/rdap). +General information on RDAP can be found [here](https://rdap.rcode3.com/). Installing the RDAP Client -------------------------- @@ -79,7 +80,7 @@ Output Format ------------- By default, the client will attempt to determine the output format of the information. If it determines the shell -is interactive, output will be in `rendered-markdown`. Otherwise the output will be JSON. +is interactive, output will be in `rendered-markdown`. Otherwise, the output will be JSON. You can explicitly control this behavior using the `-O` command argument or the `RDAP_OUTPUT` environment variable (see below). diff --git a/icann-rdap-cli/src/query.rs b/icann-rdap-cli/src/query.rs index 0acbc32..61bb57a 100644 --- a/icann-rdap-cli/src/query.rs +++ b/icann-rdap-cli/src/query.rs @@ -80,6 +80,7 @@ async fn do_domain_query<'a, W: std::io::Write>( let mut transactions = RequestResponses::new(); let base_url = get_base_url(&processing_params.bootstrap_type, client, query_type).await?; let response = do_request(&base_url, query_type, processing_params, client).await; + let registrar_response; match response { Ok(response) => { let source_host = response.http_data.host.to_owned(); @@ -106,11 +107,12 @@ async fn do_domain_query<'a, W: std::io::Write>( if let Some(url) = get_related_link(&response.rdap).first() { info!("Querying domain name from registrar."); let query_type = QueryType::Url(url.to_string()); - let registrar_response = + let registrar_response_result = do_request(&base_url, &query_type, processing_params, client).await; - match registrar_response { - Ok(registrar_response) => { - regr_source_host = registrar_response.http_data.host; + match registrar_response_result { + Ok(response_data) => { + registrar_response = response_data; + regr_source_host = registrar_response.http_data.host.to_owned(); regr_req_data = RequestData { req_number: 2, source_host: ®r_source_host, @@ -119,7 +121,7 @@ async fn do_domain_query<'a, W: std::io::Write>( transactions = do_output( processing_params, ®r_req_data, - &response, + ®istrar_response, write, transactions, )?; @@ -363,10 +365,14 @@ fn get_related_link(rdap_response: &RdapResponse) -> Vec<&str> { let urls: Vec<&str> = links .iter() .filter(|l| { - if let Some(rel) = &l.rel { - if let Some(media_type) = &l.media_type { - rel.eq_ignore_ascii_case("related") - && media_type.eq_ignore_ascii_case(RDAP_MEDIA_TYPE) + if l.href.as_ref().is_some() { + if let Some(rel) = &l.rel { + if let Some(media_type) = &l.media_type { + rel.eq_ignore_ascii_case("related") + && media_type.eq_ignore_ascii_case(RDAP_MEDIA_TYPE) + } else { + false + } } else { false } @@ -374,7 +380,7 @@ fn get_related_link(rdap_response: &RdapResponse) -> Vec<&str> { false } }) - .map(|l| l.href.as_str()) + .map(|l| l.href.as_ref().unwrap().as_str()) .collect::>(); urls } else { diff --git a/icann-rdap-cli/src/request.rs b/icann-rdap-cli/src/request.rs index 34a98fa..bdf4c90 100644 --- a/icann-rdap-cli/src/request.rs +++ b/icann-rdap-cli/src/request.rs @@ -53,33 +53,35 @@ pub(crate) async fn do_request( let response = rdap_url_request(&query_url, client).await?; if !processing_params.no_cache { if let Some(self_link) = response.rdap.get_self_link() { - if response.http_data.should_cache() { - let data = serde_json::to_string_pretty(&response)?; - let cache_contents = response.http_data.to_lines(&data)?; - let query_url = query_type.query_url(base_url)?; - let file_name = format!( - "{}.cache", - PctString::encode(query_url.chars(), URIReserved) - ); - debug!("Saving response to cache file {file_name}"); - let path = rdap_cache_path().join(file_name); - fs::write(path, &cache_contents)?; - if query_url != self_link.href { + if let Some(self_link_href) = &self_link.href { + if response.http_data.should_cache() { + let data = serde_json::to_string_pretty(&response)?; + let cache_contents = response.http_data.to_lines(&data)?; + let query_url = query_type.query_url(base_url)?; let file_name = format!( "{}.cache", - PctString::encode(self_link.href.chars(), URIReserved) + PctString::encode(query_url.chars(), URIReserved) ); debug!("Saving response to cache file {file_name}"); let path = rdap_cache_path().join(file_name); fs::write(path, &cache_contents)?; + if query_url != *self_link_href { + let file_name = format!( + "{}.cache", + PctString::encode(self_link_href.chars(), URIReserved) + ); + debug!("Saving response to cache file {file_name}"); + let path = rdap_cache_path().join(file_name); + fs::write(path, &cache_contents)?; + } + } else { + debug!("Not caching data according to server policy."); + debug!("Expires header: {:?}", &response.http_data.expires); + debug!( + "Cache-control header: {:?}", + &response.http_data.cache_control + ); } - } else { - debug!("Not caching data according to server policy."); - debug!("Expires header: {:?}", &response.http_data.expires); - debug!( - "Cache-control header: {:?}", - &response.http_data.cache_control - ); } } } diff --git a/icann-rdap-cli/tests/integration/queries.rs b/icann-rdap-cli/tests/integration/queries.rs index a4dec24..a2abd73 100644 --- a/icann-rdap-cli/tests/integration/queries.rs +++ b/icann-rdap-cli/tests/integration/queries.rs @@ -175,3 +175,21 @@ async fn GIVEN_idn_WHEN_query_a_label_THEN_success() { let assert = test_jig.cmd.assert(); assert.success(); } + +#[tokio::test(flavor = "multi_thread")] +async fn GIVEN_domain_WHEN_search_domain_names_THEN_success() { + // GIVEN + let mut test_jig = TestJig::new_with_enable_domain_name_search().await; + let mut tx = test_jig.mem.new_tx().await.expect("new transaction"); + tx.add_domain(&Domain::basic().ldh_name("foo.example").build()) + .await + .expect("add domain in tx"); + tx.commit().await.expect("tx commit"); + + // WHEN + test_jig.cmd.arg("-t").arg("domain-name").arg("foo.*"); + + // THEN + let assert = test_jig.cmd.assert(); + assert.success(); +} diff --git a/icann-rdap-cli/tests/integration/test_jig.rs b/icann-rdap-cli/tests/integration/test_jig.rs index 5e5db3e..a762784 100644 --- a/icann-rdap-cli/tests/integration/test_jig.rs +++ b/icann-rdap-cli/tests/integration/test_jig.rs @@ -2,7 +2,9 @@ use assert_cmd::Command; use icann_rdap_srv::config::ListenConfig; use icann_rdap_srv::server::AppState; use icann_rdap_srv::server::Listener; +use icann_rdap_srv::storage::mem::config::MemConfig; use icann_rdap_srv::storage::mem::ops::Mem; +use icann_rdap_srv::storage::CommonConfig; use std::time::Duration; use test_dir::DirBuilder; use test_dir::FileType; @@ -18,7 +20,19 @@ pub struct TestJig { impl TestJig { pub async fn new() -> TestJig { - let mem = Mem::default(); + let common_config = CommonConfig::default(); + TestJig::new_common_config(common_config).await + } + + pub async fn new_with_enable_domain_name_search() -> TestJig { + let common_config = CommonConfig::builder() + .domain_search_by_name_enable(true) + .build(); + TestJig::new_common_config(common_config).await + } + + pub async fn new_common_config(common_config: CommonConfig) -> TestJig { + let mem = Mem::new(MemConfig::builder().common_config(common_config).build()); let app_state = AppState { storage: mem.clone(), bootstrap: false, diff --git a/icann-rdap-client/Cargo.toml b/icann-rdap-client/Cargo.toml index 1ade6e8..b5e4001 100644 --- a/icann-rdap-client/Cargo.toml +++ b/icann-rdap-client/Cargo.toml @@ -10,7 +10,7 @@ An RDAP client library. [dependencies] -icann-rdap-common = { version = "0.0.17", path = "../icann-rdap-common" } +icann-rdap-common = { version = "0.0.18", path = "../icann-rdap-common" } buildstructor.workspace = true cidr-utils.workspace = true diff --git a/icann-rdap-client/README.md b/icann-rdap-client/README.md index cfec3bd..ccb0086 100644 --- a/icann-rdap-client/README.md +++ b/icann-rdap-client/README.md @@ -6,6 +6,7 @@ by the Internet Corporation for Assigned Names and Numbers [(ICANN)](https://www RDAP is standard of the [IETF](https://ietf.org/), and extensions to RDAP are a current work activity of the IETF's [REGEXT working group](https://datatracker.ietf.org/wg/regext/documents/). More information on ICANN's role in RDAP can be found [here](https://www.icann.org/rdap). +General information on RDAP can be found [here](https://rdap.rcode3.com/). Installation ------------ @@ -19,10 +20,11 @@ Both icann-rdap-common and icann-rdap-client can be compiled for WASM targets. Usage ----- -In RDAP, bootstrapping is the process of finding the authoritative RDAP server to +In RDAP, [bootstrapping](https://rdap.rcode3.com/bootstrapping/iana.html) +is the process of finding the authoritative RDAP server to query using the IANA RDAP bootstrap files. To make a query using bootstrapping: -```no_run +```rust,no_run use icann_rdap_client::*; use std::str::FromStr; use tokio::main; diff --git a/icann-rdap-client/src/gtld/domain.rs b/icann-rdap-client/src/gtld/domain.rs index bde9988..0af5dfd 100644 --- a/icann-rdap-client/src/gtld/domain.rs +++ b/icann-rdap-client/src/gtld/domain.rs @@ -75,24 +75,26 @@ fn format_registry_dates(events: &Option>) -> String { let mut formatted_dates = String::new(); if let Some(events) = events { for event in events { - match event.event_action.as_str() { - "last changed" => { - if let Some(event_date) = &event.event_date { - formatted_dates.push_str(&format!("Updated Date: {}\n", event_date)); + if let Some(event_action) = &event.event_action { + match event_action.as_str() { + "last changed" => { + if let Some(event_date) = &event.event_date { + formatted_dates.push_str(&format!("Updated Date: {}\n", event_date)); + } } - } - "registration" => { - if let Some(event_date) = &event.event_date { - formatted_dates.push_str(&format!("Creation Date: {}\n", event_date)); + "registration" => { + if let Some(event_date) = &event.event_date { + formatted_dates.push_str(&format!("Creation Date: {}\n", event_date)); + } } - } - "expiration" => { - if let Some(event_date) = &event.event_date { - formatted_dates - .push_str(&format!("Registry Expiry Date: {}\n", event_date)); + "expiration" => { + if let Some(event_date) = &event.event_date { + formatted_dates + .push_str(&format!("Registry Expiry Date: {}\n", event_date)); + } } + _ => {} } - _ => {} } } } @@ -163,14 +165,16 @@ fn format_dnssec_info(secure_dns: &Option) -> String { fn format_last_update_info(events: &Option>, gtld: &mut String) { if let Some(events) = events { for event in events { - if event.event_action == "last update of RDAP database" { - if let Some(event_date) = &event.event_date { - gtld.push_str(&format!( - ">>> Last update of RDAP database: {} <<<\n", - event_date - )); + if let Some(event_action) = &event.event_action { + if event_action == "last update of RDAP database" { + if let Some(event_date) = &event.event_date { + gtld.push_str(&format!( + ">>> Last update of RDAP database: {} <<<\n", + event_date + )); + } + break; } - break; } } } diff --git a/icann-rdap-client/src/gtld/entity.rs b/icann-rdap-client/src/gtld/entity.rs index 432c309..64ae409 100644 --- a/icann-rdap-client/src/gtld/entity.rs +++ b/icann-rdap-client/src/gtld/entity.rs @@ -34,13 +34,17 @@ impl ToGtldWhois for Option> { // Special Sauce for Registrar IANA ID and Abuse Contact if let Some(public_ids) = &entity.public_ids { for public_id in public_ids { - if public_id.id_type.as_str() == "IANA Registrar ID" - && !public_id.identifier.is_empty() - { - front_formatted_data += &format!( - "Registrar IANA ID: {}\n", - public_id.identifier.clone() - ); + if let Some(id_type) = &public_id.id_type { + if let Some(identifier) = &public_id.identifier { + if id_type.as_str() == "IANA Registrar ID" + && !identifier.is_empty() + { + front_formatted_data += &format!( + "Registrar IANA ID: {}\n", + identifier.clone() + ); + } + } } } } @@ -167,13 +171,10 @@ fn extract_role_info( if let Some(properties) = vcard.as_array() { for property in properties { if let Some(property) = property.as_array() { - match property[0].as_str().unwrap_or("") { - "adr" => { - if let Some(address_components) = property[3].as_array() { - adr = format_address_with_label(params, address_components); - } + if let "adr" = property[0].as_str().unwrap_or("") { + if let Some(address_components) = property[3].as_array() { + adr = format_address_with_label(params, address_components); } - _ => {} } } } diff --git a/icann-rdap-client/src/gtld/mod.rs b/icann-rdap-client/src/gtld/mod.rs index d8e501b..5c2ad91 100644 --- a/icann-rdap-client/src/gtld/mod.rs +++ b/icann-rdap-client/src/gtld/mod.rs @@ -1,3 +1,5 @@ +//! Converts RDAP structures to gTLD Whois output. + use icann_rdap_common::contact::PostalAddress; use icann_rdap_common::response::RdapResponse; use std::any::TypeId; diff --git a/icann-rdap-client/src/md/entity.rs b/icann-rdap-client/src/md/entity.rs index 94ac008..f4c5c11 100644 --- a/icann-rdap-client/src/md/entity.rs +++ b/icann-rdap-client/src/md/entity.rs @@ -45,11 +45,14 @@ impl ToMd for Entity { .and_data_ref_maybe(&"Kind", &contact.kind) .and_data_ref_maybe(&"Full Name", &contact.full_name) .and_data_ul(&"Titles", contact.titles) + .and_data_ul(&"Org Roles", contact.roles) .and_data_ul(&"Nicknames", contact.nick_names) .and_data_ul(&"Organization Names", contact.organization_names) .and_data_ul(&"Languages", contact.langs) .and_data_ul(&"Phones", contact.phones) - .and_data_ul(&"Emails", contact.emails); + .and_data_ul(&"Emails", contact.emails) + .and_data_ul(&"Web Contact", contact.contact_uris) + .and_data_ul(&"URLs", contact.urls); table = contact.postal_addresses.add_to_mptable(table, params); table = contact.name_parts.add_to_mptable(table, params) } diff --git a/icann-rdap-client/src/md/mod.rs b/icann-rdap-client/src/md/mod.rs index 93d1687..229a8c1 100644 --- a/icann-rdap-client/src/md/mod.rs +++ b/icann-rdap-client/src/md/mod.rs @@ -1,3 +1,5 @@ +//! Converts RDAP to Markdown. + use crate::request::RequestData; use icann_rdap_common::{check::CheckParams, response::RdapResponse}; use std::{any::TypeId, char}; diff --git a/icann-rdap-client/src/md/redacted.rs b/icann-rdap-client/src/md/redacted.rs index 3a35558..f439b49 100644 --- a/icann-rdap-client/src/md/redacted.rs +++ b/icann-rdap-client/src/md/redacted.rs @@ -222,7 +222,7 @@ mod tests { if let Some(redacted_array) = redacted_array_option { crate::md::redacted::convert_redactions(&mut rdap_json_response, &redacted_array); } else { - assert!(false, "No redacted array found in the JSON"); + panic!("No redacted array found in the JSON"); } let pretty_json = serde_json::to_string_pretty(&rdap_json_response)?; println!("{}", pretty_json); diff --git a/icann-rdap-client/src/md/types.rs b/icann-rdap-client/src/md/types.rs index 6d67f43..0eb76dd 100644 --- a/icann-rdap-client/src/md/types.rs +++ b/icann-rdap-client/src/md/types.rs @@ -71,7 +71,9 @@ impl ToMd for Link { if let Some(rel) = &self.rel { md.push_str(&format!("[{rel}] ")); }; - md.push_str(&self.href.to_owned().to_inline(params.options)); + if let Some(href) = &self.href { + md.push_str(&href.to_owned().to_inline(params.options)); + } md.push(' '); if let Some(media_type) = &self.media_type { md.push_str(&format!("of type '{media_type}' ")); @@ -83,7 +85,14 @@ impl ToMd for Link { md.push_str(&format!("for {value} ",)); }; if let Some(hreflang) = &self.hreflang { - md.push_str(&format!("in languages {}", hreflang.join(", "))); + match hreflang { + icann_rdap_common::response::types::HrefLang::Lang(lang) => { + md.push_str(&format!("in language {}", lang)); + } + icann_rdap_common::response::types::HrefLang::Langs(langs) => { + md.push_str(&format!("in languages {}", langs.join(", "))); + } + } }; md.push('\n'); let checks = self.get_checks(CheckParams::from_md(params, TypeId::of::())); @@ -127,9 +136,11 @@ impl ToMd for NoticeOrRemark { if let Some(title) = &self.title { md.push_str(&format!("{}\n", title.to_bold(params.options))); }; - self.description - .iter() - .for_each(|s| md.push_str(&format!("> {}\n\n", s.trim()))); + if let Some(description) = &self.description { + description + .iter() + .for_each(|s| md.push_str(&format!("> {}\n\n", s.trim()))); + } self.get_checks(CheckParams::from_md(params, TypeId::of::())) .items .iter() @@ -216,7 +227,12 @@ pub(crate) fn public_ids_to_table( mut table: MultiPartTable, ) -> MultiPartTable { for pid in publid_ids { - table = table.data_ref(&pid.id_type, &pid.identifier); + table = table.data_ref( + pid.id_type.as_ref().unwrap_or(&"(not given)".to_string()), + pid.identifier + .as_ref() + .unwrap_or(&"(not given)".to_string()), + ); } table } @@ -239,7 +255,15 @@ pub(crate) fn events_to_table( if let Some(event_actor) = &event.event_actor { ul.push(event_actor); } - table = table.data_ul_ref(&event.event_action.to_owned().to_words_title_case(), ul); + table = table.data_ul_ref( + &event + .event_action + .as_ref() + .unwrap_or(&"action not given".to_string()) + .to_owned() + .to_words_title_case(), + ul, + ); } table } @@ -259,7 +283,10 @@ pub(crate) fn links_to_table( .as_ref() .unwrap_or(&"Link".to_string()) .to_title_case(); - let mut ul: Vec<&String> = vec![&link.href]; + let mut ul: Vec<&String> = vec![]; + if let Some(href) = &link.href { + ul.push(href) + } if let Some(media_type) = &link.media_type { ul.push(media_type) }; @@ -271,7 +298,10 @@ pub(crate) fn links_to_table( }; let hreflang_s; if let Some(hreflang) = &link.hreflang { - hreflang_s = hreflang.join(", "); + hreflang_s = match hreflang { + icann_rdap_common::response::types::HrefLang::Lang(lang) => lang.to_owned(), + icann_rdap_common::response::types::HrefLang::Langs(langs) => langs.join(", "), + }; ul.push(&hreflang_s) }; table = table.data_ul_ref(&rel, ul); diff --git a/icann-rdap-client/src/query/bootstrap.rs b/icann-rdap-client/src/query/bootstrap.rs index 84fe332..afc2692 100644 --- a/icann-rdap-client/src/query/bootstrap.rs +++ b/icann-rdap-client/src/query/bootstrap.rs @@ -1,3 +1,5 @@ +//! Does RDAP query bootstrapping. + use std::sync::{Arc, RwLock}; use icann_rdap_common::{ diff --git a/icann-rdap-client/src/query/mod.rs b/icann-rdap-client/src/query/mod.rs index a722ea9..53d4367 100644 --- a/icann-rdap-client/src/query/mod.rs +++ b/icann-rdap-client/src/query/mod.rs @@ -1,3 +1,5 @@ +//! Code for issuing RDAP queries. + pub mod bootstrap; pub mod qtype; pub mod request; diff --git a/icann-rdap-client/src/query/qtype.rs b/icann-rdap-client/src/query/qtype.rs index 3b6a210..a450039 100644 --- a/icann-rdap-client/src/query/qtype.rs +++ b/icann-rdap-client/src/query/qtype.rs @@ -1,3 +1,4 @@ +//! Defines the various types of RDAP queries. use std::{net::IpAddr, str::FromStr}; use cidr_utils::cidr::IpInet; diff --git a/icann-rdap-client/src/query/request.rs b/icann-rdap-client/src/query/request.rs index d1ffa25..607c2cc 100644 --- a/icann-rdap-client/src/query/request.rs +++ b/icann-rdap-client/src/query/request.rs @@ -1,3 +1,5 @@ +//! Functions to make RDAP requests. + use icann_rdap_common::{cache::HttpData, iana::IanaRegistryType, response::RdapResponse}; use reqwest::{ header::{CACHE_CONTROL, CONTENT_TYPE, EXPIRES, LOCATION}, diff --git a/icann-rdap-client/src/request.rs b/icann-rdap-client/src/request.rs index ff5b27d..4e5022c 100644 --- a/icann-rdap-client/src/request.rs +++ b/icann-rdap-client/src/request.rs @@ -1,9 +1,12 @@ +//! Structures that describe a request. + use icann_rdap_common::check::Checks; use serde::{Deserialize, Serialize}; use strum_macros::Display; use crate::query::request::ResponseData; +/// Types of RDAP servers. #[derive(Serialize, Deserialize, Display, Clone, Copy)] pub enum SourceType { #[strum(serialize = "Domain Registry")] @@ -32,6 +35,7 @@ pub struct RequestData<'a> { pub source_type: SourceType, } +/// Structure for serializing request and response data. #[derive(Clone, Serialize)] pub struct RequestResponse<'a> { pub req_data: &'a RequestData<'a>, diff --git a/icann-rdap-common/README.md b/icann-rdap-common/README.md index da188c0..0f4381d 100644 --- a/icann-rdap-common/README.md +++ b/icann-rdap-common/README.md @@ -6,6 +6,7 @@ by the Internet Corporation for Assigned Names and Numbers [(ICANN)](https://www RDAP is standard of the [IETF](https://ietf.org/), and extensions to RDAP are a current work activity of the IETF's [REGEXT working group](https://datatracker.ietf.org/wg/regext/documents/). More information on ICANN's role in RDAP can be found [here](https://www.icann.org/rdap). +General information on RDAP can be found [here](https://rdap.rcode3.com/). Installation @@ -78,6 +79,88 @@ let rdap: RdapResponse = serde_json::from_str(json).unwrap(); assert!(matches!(rdap, RdapResponse::Network(_))); ``` +RDAP uses jCard, the JSON version of vCard, to model "contact information" +(e.g. postal addresses, phone numbers, etc...). Because jCard is difficult +to use and there might be other contact models standardized by the IETF, +this library includes the [`contact::Contact`] struct. This struct can be +converted to and from jCard/vCard with the [`contact::Contact::from_vcard`] +and [`contact::Contact::to_vcard`] functions. + +[`contact::Contact`] structs can be built using the builder. + +```rust +use icann_rdap_common::contact::Contact; + +let contact = Contact::builder() + .kind("individual") + .full_name("Bob Smurd") + .build(); +``` + +Once built, a Contact struct can be converted to an array of [serde_json::Value]'s, +which can be used with serde to serialize to JSON. + +```rust +use icann_rdap_common::contact::Contact; +use serde::Serialize; +use serde_json::Value; + +let contact = Contact::builder() + .kind("individual") + .full_name("Bob Smurd") + .build(); + +let v = contact.to_vcard(); +let json = serde_json::to_string(&v); +``` + +To deserialize, use the `from_vcard` function. + +```rust +use icann_rdap_common::contact::Contact; +use serde::Deserialize; +use serde_json::Value; + +let json = r#" +[ + "vcard", + [ + ["version", {}, "text", "4.0"], + ["fn", {}, "text", "Joe User"], + ["kind", {}, "text", "individual"], + ["org", { + "type":"work" + }, "text", "Example"], + ["title", {}, "text", "Research Scientist"], + ["role", {}, "text", "Project Lead"], + ["adr", + { "type":"work" }, + "text", + [ + "", + "Suite 1234", + "4321 Rue Somewhere", + "Quebec", + "QC", + "G1V 2M2", + "Canada" + ] + ], + ["tel", + { "type":["work", "voice"], "pref":"1" }, + "uri", "tel:+1-555-555-1234;ext=102" + ], + ["email", + { "type":"work" }, + "text", "joe.user@example.com" + ] + ] +]"#; + +let data: Vec = serde_json::from_str(json).unwrap(); +let contact = Contact::from_vcard(&data); +``` + License ------- diff --git a/icann-rdap-common/src/cache.rs b/icann-rdap-common/src/cache.rs index 62f81f0..27fdd3e 100644 --- a/icann-rdap-common/src/cache.rs +++ b/icann-rdap-common/src/cache.rs @@ -1,3 +1,5 @@ +//! Code for handling HTTP caching. + use buildstructor::Builder; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; diff --git a/icann-rdap-common/src/check/autnum.rs b/icann-rdap-common/src/check/autnum.rs index e3acb3f..c10744c 100644 --- a/icann-rdap-common/src/check/autnum.rs +++ b/icann-rdap-common/src/check/autnum.rs @@ -2,7 +2,7 @@ use std::any::TypeId; use crate::response::autnum::Autnum; -use super::{string::StringCheck, CheckItem, CheckParams, Checks, GetChecks, GetSubChecks}; +use super::{string::StringCheck, Check, CheckParams, Checks, GetChecks, GetSubChecks}; impl GetChecks for Autnum { fn get_checks(&self, params: CheckParams) -> super::Checks { @@ -23,13 +23,13 @@ impl GetChecks for Autnum { let mut items = Vec::new(); if self.start_autnum.is_none() || self.end_autnum.is_none() { - items.push(CheckItem::missing_autnum()) + items.push(Check::AutnumMissing.check_item()) } if let Some(start_num) = &self.start_autnum { if let Some(end_num) = &self.end_autnum { if start_num > end_num { - items.push(CheckItem::end_autnum_before_start_autnum()) + items.push(Check::AutnumEndBeforeStart.check_item()) } if *start_num == 0 || *start_num == 65535 @@ -38,34 +38,34 @@ impl GetChecks for Autnum { || *end_num == 65535 || *end_num == 4294967295 { - items.push(CheckItem::reserved_autnum()) + items.push(Check::AutnumReserved.check_item()) } if (64496..=64511).contains(start_num) || (64496..=64511).contains(end_num) || (65536..=65551).contains(start_num) || (65536..=65551).contains(end_num) { - items.push(CheckItem::documentation_autnum()) + items.push(Check::AutnumDocumentation.check_item()) } if (64512..=65534).contains(start_num) || (64512..=65534).contains(end_num) || (64512..=65534).contains(start_num) || (64512..=65534).contains(end_num) { - items.push(CheckItem::private_use_autnum()) + items.push(Check::AutnumPrivateUse.check_item()) } } } if let Some(name) = &self.name { if name.is_whitespace_or_empty() { - items.push(CheckItem::name_is_empty()) + items.push(Check::NetworkOrAutnumNameIsEmpty.check_item()) } } if let Some(autnum_type) = &self.autnum_type { if autnum_type.is_whitespace_or_empty() { - items.push(CheckItem::type_is_empty()) + items.push(Check::NetworkOrAutnumTypeIsEmpty.check_item()) } } @@ -102,7 +102,10 @@ mod tests { // THEN dbg!(&checks); - assert!(checks.items.iter().any(|c| c.check == Check::NameIsEmpty)); + assert!(checks + .items + .iter() + .any(|c| c.check == Check::NetworkOrAutnumNameIsEmpty)); } #[test] @@ -121,6 +124,9 @@ mod tests { // THEN dbg!(&checks); - assert!(checks.items.iter().any(|c| c.check == Check::TypeIsEmpty)); + assert!(checks + .items + .iter() + .any(|c| c.check == Check::NetworkOrAutnumTypeIsEmpty)); } } diff --git a/icann-rdap-common/src/check/domain.rs b/icann-rdap-common/src/check/domain.rs index 80501c1..8ac4fb8 100644 --- a/icann-rdap-common/src/check/domain.rs +++ b/icann-rdap-common/src/check/domain.rs @@ -2,7 +2,7 @@ use std::any::TypeId; use crate::response::domain::Domain; -use super::{string::StringCheck, CheckItem, CheckParams, Checks, GetChecks, GetSubChecks}; +use super::{string::StringCheck, Check, CheckParams, Checks, GetChecks, GetSubChecks}; impl GetChecks for Domain { fn get_checks(&self, params: CheckParams) -> super::Checks { @@ -15,6 +15,9 @@ impl GetChecks for Domain { .object_common .get_sub_checks(params.from_parent(TypeId::of::())), ); + if let Some(public_ids) = &self.public_ids { + sub_checks.append(&mut public_ids.get_sub_checks(params)); + } sub_checks } else { Vec::new() @@ -31,14 +34,14 @@ impl GetChecks for Domain { }) .count(); if empty_count != 0 { - items.push(CheckItem::empty_domain_variant()); + items.push(Check::VariantEmptyDomain.check_item()); }; }; // check ldh if let Some(ldh) = &self.ldh_name { if !ldh.is_ldh_domain_name() { - items.push(CheckItem::invalid_ldh_name()); + items.push(Check::LdhNameInvalid.check_item()); } let name = ldh.trim_end_matches('.'); if name.eq("example") @@ -50,7 +53,7 @@ impl GetChecks for Domain { || name.eq("example.org") || name.ends_with(".example.org") { - items.push(CheckItem::documentation_name()) + items.push(Check::LdhNameDocumentation.check_item()) } // if there is also a unicodeName @@ -58,7 +61,7 @@ impl GetChecks for Domain { let expected = idna::domain_to_ascii(unicode_name); if let Ok(expected) = expected { if !expected.eq_ignore_ascii_case(ldh) { - items.push(CheckItem::unicode_does_not_match_ldh()) + items.push(Check::LdhNameDoesNotMatchUnicode.check_item()) } } } @@ -67,11 +70,11 @@ impl GetChecks for Domain { // check unicode_name if let Some(unicode_name) = &self.unicode_name { if !unicode_name.is_unicode_domain_name() { - items.push(CheckItem::invalid_unicode_domain_name()); + items.push(Check::UnicodeNameInvalidDomain.check_item()); } let expected = idna::domain_to_ascii(unicode_name); if expected.is_err() { - items.push(CheckItem::invalid_unicode_name()); + items.push(Check::UnicodeNameInvalidUnicode.check_item()); } } @@ -112,7 +115,7 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::InvalidLdhName)); + .any(|c| c.check == Check::LdhNameInvalid)); } #[rstest] @@ -135,7 +138,7 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::InvalidUnicodeDomainName)); + .any(|c| c.check == Check::UnicodeNameInvalidDomain)); } #[test] @@ -159,6 +162,6 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::UnicodeDoesNotMatchLdh)); + .any(|c| c.check == Check::LdhNameDoesNotMatchUnicode)); } } diff --git a/icann-rdap-common/src/check/entity.rs b/icann-rdap-common/src/check/entity.rs index cc774cf..d7a8714 100644 --- a/icann-rdap-common/src/check/entity.rs +++ b/icann-rdap-common/src/check/entity.rs @@ -4,7 +4,7 @@ use crate::{contact::Contact, response::entity::Entity}; use super::{ string::{StringCheck, StringListCheck}, - CheckItem, CheckParams, Checks, GetChecks, GetSubChecks, + Check, CheckParams, Checks, GetChecks, GetSubChecks, }; impl GetChecks for Entity { @@ -18,6 +18,9 @@ impl GetChecks for Entity { .object_common .get_sub_checks(params.from_parent(TypeId::of::())), ); + if let Some(public_ids) = &self.public_ids { + sub_checks.append(&mut public_ids.get_sub_checks(params)); + } sub_checks } else { Vec::new() @@ -27,7 +30,7 @@ impl GetChecks for Entity { if let Some(roles) = &self.roles { if roles.as_slice().is_empty_or_any_empty_or_whitespace() { - items.push(CheckItem::roles_are_empty()); + items.push(Check::RoleIsEmpty.check_item()); } } @@ -35,13 +38,13 @@ impl GetChecks for Entity { if let Some(contact) = Contact::from_vcard(vcard) { if let Some(full_name) = contact.full_name { if full_name.is_whitespace_or_empty() { - items.push(CheckItem::vcard_fn_is_empty()) + items.push(Check::VcardFnIsEmpty.check_item()) } } else { - items.push(CheckItem::vcard_has_no_fn()) + items.push(Check::VcardHasNoFn.check_item()) } } else { - items.push(CheckItem::vcard_array_is_empty()) + items.push(Check::VcardArrayIsEmpty.check_item()) } } diff --git a/icann-rdap-common/src/check/items.rs b/icann-rdap-common/src/check/items.rs deleted file mode 100644 index 373c6c9..0000000 --- a/icann-rdap-common/src/check/items.rs +++ /dev/null @@ -1,300 +0,0 @@ -use super::{Check, CheckClass, CheckItem}; - -impl CheckItem { - // RDAP Conformance - - pub fn invalid_rdap_conformance_parent() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::InvalidRdapConformanceParent, - } - } - - // Links - - pub fn link_missing_value_property() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::LinkMissingValueProperty, - } - } - pub fn related_link_is_not_rdap() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::RelatedLinkIsNotRdap, - } - } - pub fn related_link_has_no_type() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::RelatedLinkHasNoType, - } - } - pub fn self_link_is_not_rdap() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::SelfLinkIsNotRdap, - } - } - pub fn self_link_has_no_type() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::SelfLinkHasNoType, - } - } - pub fn object_class_has_no_self_link() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::ObjectClassHasNoSelfLink, - } - } - pub fn link_missing_rel_property() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::LinkMissingRelProperty, - } - } - - // Variants - - pub fn empty_domain_variant() -> CheckItem { - CheckItem { - check_class: super::CheckClass::SpecificationWarning, - check: Check::EmptyDomainVariant, - } - } - - // Events - pub fn event_date_is_absent() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::EventDateIsAbsent, - } - } - pub fn event_date_is_not_rfc3339() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::EventDateIsNotRfc3339, - } - } - - // Handle - pub fn handle_is_empty() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::HandleIsEmpty, - } - } - - // Status - pub fn status_is_empty() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::StatusIsEmpty, - } - } - - // Roles - pub fn roles_are_empty() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::RolesAreEmpty, - } - } - - // LDH Name - pub fn invalid_ldh_name() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::InvalidLdhName, - } - } - pub fn documentation_name() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::DocumentataionName, - } - } - pub fn unicode_does_not_match_ldh() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::UnicodeDoesNotMatchLdh, - } - } - - // Unicode Name - pub fn invalid_unicode_domain_name() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::InvalidUnicodeDomainName, - } - } - pub fn invalid_unicode_name() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::InvalidUnicodeName, - } - } - - // Network or Autnum Name - pub fn name_is_empty() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::NameIsEmpty, - } - } - - // Network or Autnum Type - pub fn type_is_empty() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::TypeIsEmpty, - } - } - - // IP Address - pub fn missing_ip_address() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::MissingIpAddress, - } - } - pub fn malformed_ip_address() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::MalformedIpAddress, - } - } - pub fn end_ip_before_start_ip() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::EndIpBeforeStartIp, - } - } - pub fn ip_version_mismatch() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::IpVersionMismatch, - } - } - pub fn malfomred_ip_version() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::MalformedIPVersion, - } - } - pub fn ip_address_list_is_empty() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::IpAddressListIsEmpty, - } - } - pub fn this_network() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::ThisNetwork, - } - } - pub fn private_use_ip() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::PrivateUseIp, - } - } - pub fn shared_nat_ip() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::SharedNatIp, - } - } - pub fn loopback() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::Loopback, - } - } - pub fn linklocal() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::LinkLocal, - } - } - pub fn unique_local() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::UniqueLocal, - } - } - pub fn documentation_net() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::DocumentationNet, - } - } - pub fn reserved_net() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::ReservedNet, - } - } - - // Autnum - pub fn missing_autnum() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::MissingAutnum, - } - } - pub fn end_autnum_before_start_autnum() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::EndAutnumBeforeStartAutnum, - } - } - pub fn private_use_autnum() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::PrivateUseAutnum, - } - } - pub fn documentation_autnum() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::DocumentationAutnum, - } - } - pub fn reserved_autnum() -> CheckItem { - CheckItem { - check_class: CheckClass::Informational, - check: Check::ReservedAutnum, - } - } - - // VCard - pub fn vcard_array_is_empty() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::VcardArrayIsEmpty, - } - } - pub fn vcard_has_no_fn() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::VcardHasNoFn, - } - } - pub fn vcard_fn_is_empty() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationWarning, - check: Check::VcardFnIsEmpty, - } - } - - // Port43 - pub fn port43_is_empty() -> CheckItem { - CheckItem { - check_class: CheckClass::SpecificationError, - check: Check::Port43IsEmpty, - } - } -} diff --git a/icann-rdap-common/src/check/mod.rs b/icann-rdap-common/src/check/mod.rs index 19d4e60..be8a3c1 100644 --- a/icann-rdap-common/src/check/mod.rs +++ b/icann-rdap-common/src/check/mod.rs @@ -1,3 +1,5 @@ +//! Conformance checks of RDAP structures. + use std::any::TypeId; use crate::response::RdapResponse; @@ -11,7 +13,6 @@ pub mod domain; pub mod entity; pub mod error; pub mod help; -pub mod items; pub mod nameserver; pub mod network; pub mod search; @@ -148,33 +149,41 @@ where pub enum Check { // RDAP Conformance #[strum(message = "'rdapConformance' can only appear at the top of response.")] - InvalidRdapConformanceParent, + RdapConformanceInvalidParent, - // Links + // Link #[strum(message = "'value' property not found in Link structure as required by RFC 9083")] LinkMissingValueProperty, #[strum(message = "'rel' property not found in Link structure as required by RFC 9083")] LinkMissingRelProperty, #[strum(message = "ambiguous follow because related link has no 'type' property")] - RelatedLinkHasNoType, + LinkRelatedHasNoType, #[strum(message = "ambiguous follow because related link does not have RDAP media type")] - RelatedLinkIsNotRdap, + LinkRelatedIsNotRdap, #[strum(message = "self link has no 'type' property")] - SelfLinkHasNoType, + LinkSelfHasNoType, #[strum(message = "self link does not have RDAP media type")] - SelfLinkIsNotRdap, + LinkSelfIsNotRdap, #[strum(message = "RFC 9083 recommends self links for all object classes")] - ObjectClassHasNoSelfLink, + LinkObjectClassHasNoSelf, + #[strum(message = "'href' property not found in Link structure as required by RFC 9083")] + LinkMissingHrefProperty, - // Variants + // Variant #[strum(message = "empty domain variant is ambiguous")] - EmptyDomainVariant, + VariantEmptyDomain, - // Events + // Event #[strum(message = "event date is absent")] EventDateIsAbsent, #[strum(message = "event date is not RFC 3339 compliant")] EventDateIsNotRfc3339, + #[strum(message = "event action is absent")] + EventActionIsAbsent, + + // Notice Or Remark + #[strum(message = "RFC 9083 requires a description in a notice or remark")] + NoticeOrRemarkDescriptionIsAbsent, // Handle #[strum(message = "handle appears to be empty or only whitespace")] @@ -184,75 +193,75 @@ pub enum Check { #[strum(message = "status appears to be empty or only whitespace")] StatusIsEmpty, - // Roles - #[strum(message = "roles appears to be empty or only whitespace")] - RolesAreEmpty, + // Role + #[strum(message = "role appears to be empty or only whitespace")] + RoleIsEmpty, // LDH Name #[strum(message = "ldhName does not appear to be an LDH name")] - InvalidLdhName, + LdhNameInvalid, #[strum(message = "Documentation domain name. See RFC 6761")] - DocumentataionName, + LdhNameDocumentation, #[strum(message = "Unicode name does not match LDH")] - UnicodeDoesNotMatchLdh, + LdhNameDoesNotMatchUnicode, - // Unicode Name + // Unicode Nmae #[strum(message = "unicodeName does not appear to be a domain name")] - InvalidUnicodeDomainName, + UnicodeNameInvalidDomain, #[strum(message = "unicodeName does not appear to be valid Unicode")] - InvalidUnicodeName, + UnicodeNameInvalidUnicode, - // Network or Autnum Name + // Network Or Autnum Name #[strum(message = "name appears to be empty or only whitespace")] - NameIsEmpty, + NetworkOrAutnumNameIsEmpty, // Network or Autnum Type #[strum(message = "type appears to be empty or only whitespace")] - TypeIsEmpty, + NetworkOrAutnumTypeIsEmpty, // IP Address #[strum(message = "start or end IP address is missing")] - MissingIpAddress, + IpAddressMissing, #[strum(message = "IP address is malformed")] - MalformedIpAddress, + IpAddressMalformed, #[strum(message = "end IP address comes before start IP address")] - EndIpBeforeStartIp, + IpAddressEndBeforeStart, #[strum(message = "IP version does not match IP address")] - IpVersionMismatch, + IpAddressVersionMismatch, #[strum(message = "IP version is malformed")] - MalformedIPVersion, + IpAddressMalformedVersion, #[strum(message = "IP address list is empty")] IpAddressListIsEmpty, #[strum(message = "\"This network.\" See RFC 791")] - ThisNetwork, + IpAddressThisNetwork, #[strum(message = "Private use. See RFC 1918")] - PrivateUseIp, + IpAddressPrivateUse, #[strum(message = "Shared NAT network. See RFC 6598")] - SharedNatIp, + IpAddressSharedNat, #[strum(message = "Loopback network. See RFC 1122")] - Loopback, + IpAddressLoopback, #[strum(message = "Link local network. See RFC 3927")] - LinkLocal, + IpAddressLinkLocal, #[strum(message = "Unique local network. See RFC 8190")] - UniqueLocal, + IpAddressUniqueLocal, #[strum(message = "Documentation network. See RFC 5737")] - DocumentationNet, + IpAddressDocumentationNet, #[strum(message = "Reserved network. See RFC 1112")] - ReservedNet, + IpAddressReservedNet, // Autnum #[strum(message = "start or end autnum is missing")] - MissingAutnum, + AutnumMissing, #[strum(message = "end AS number comes before start AS number")] - EndAutnumBeforeStartAutnum, + AutnumEndBeforeStart, #[strum(message = "Private use. See RFC 6996")] - PrivateUseAutnum, + AutnumPrivateUse, #[strum(message = "Documentation AS number. See RFC 5398")] - DocumentationAutnum, + AutnumDocumentation, #[strum(message = "Reserved AS number. See RFC 6996")] - ReservedAutnum, + AutnumReserved, - // VCard + // Vcard #[strum(message = "vCard array does not contain a vCard")] VcardArrayIsEmpty, #[strum(message = "vCard has no fn property")] @@ -263,6 +272,103 @@ pub enum Check { // Port 43 #[strum(message = "port43 appears to be empty or only whitespace")] Port43IsEmpty, + + // Public Id + #[strum(message = "publicId type is absent")] + PublicIdTypeIsAbsent, + #[strum(message = "publicId identifier is absent")] + PublicIdIdentifierIsAbsent, + + // Cidr0 + #[strum(message = "Cidr0 v4 prefix is absent")] + Cidr0V4PrefixIsAbsent, + #[strum(message = "Cidr0 v4 length is absent")] + Cidr0V4LengthIsAbsent, + #[strum(message = "Cidr0 v6 prefix is absent")] + Cidr0V6PrefixIsAbsent, + #[strum(message = "Cidr0 v6 length is absent")] + Cidr0V6LengthIsAbsent, +} + +impl Check { + fn check_item(self) -> CheckItem { + let check_class = match self { + Check::RdapConformanceInvalidParent => CheckClass::SpecificationError, + + Check::LinkMissingValueProperty => CheckClass::SpecificationError, + Check::LinkMissingRelProperty => CheckClass::SpecificationError, + Check::LinkRelatedHasNoType => CheckClass::SpecificationWarning, + Check::LinkRelatedIsNotRdap => CheckClass::SpecificationWarning, + Check::LinkSelfHasNoType => CheckClass::SpecificationWarning, + Check::LinkSelfIsNotRdap => CheckClass::SpecificationWarning, + Check::LinkObjectClassHasNoSelf => CheckClass::SpecificationWarning, + Check::LinkMissingHrefProperty => CheckClass::SpecificationError, + + Check::VariantEmptyDomain => CheckClass::SpecificationWarning, + + Check::EventDateIsAbsent => CheckClass::SpecificationError, + Check::EventDateIsNotRfc3339 => CheckClass::SpecificationError, + Check::EventActionIsAbsent => CheckClass::SpecificationError, + + Check::NoticeOrRemarkDescriptionIsAbsent => CheckClass::SpecificationError, + + Check::HandleIsEmpty => CheckClass::SpecificationWarning, + + Check::StatusIsEmpty => CheckClass::SpecificationError, + + Check::RoleIsEmpty => CheckClass::SpecificationError, + + Check::LdhNameInvalid => CheckClass::SpecificationError, + Check::LdhNameDocumentation => CheckClass::Informational, + Check::LdhNameDoesNotMatchUnicode => CheckClass::SpecificationWarning, + + Check::UnicodeNameInvalidDomain => CheckClass::SpecificationError, + Check::UnicodeNameInvalidUnicode => CheckClass::SpecificationError, + + Check::NetworkOrAutnumNameIsEmpty => CheckClass::SpecificationWarning, + + Check::NetworkOrAutnumTypeIsEmpty => CheckClass::SpecificationWarning, + + Check::IpAddressMissing => CheckClass::SpecificationWarning, + Check::IpAddressMalformed => CheckClass::SpecificationError, + Check::IpAddressEndBeforeStart => CheckClass::SpecificationWarning, + Check::IpAddressVersionMismatch => CheckClass::SpecificationWarning, + Check::IpAddressMalformedVersion => CheckClass::SpecificationError, + Check::IpAddressListIsEmpty => CheckClass::SpecificationError, + Check::IpAddressThisNetwork => CheckClass::Informational, + Check::IpAddressPrivateUse => CheckClass::Informational, + Check::IpAddressSharedNat => CheckClass::Informational, + Check::IpAddressLoopback => CheckClass::Informational, + Check::IpAddressLinkLocal => CheckClass::Informational, + Check::IpAddressUniqueLocal => CheckClass::Informational, + Check::IpAddressDocumentationNet => CheckClass::Informational, + Check::IpAddressReservedNet => CheckClass::Informational, + + Check::AutnumMissing => CheckClass::SpecificationWarning, + Check::AutnumEndBeforeStart => CheckClass::SpecificationWarning, + Check::AutnumPrivateUse => CheckClass::Informational, + Check::AutnumDocumentation => CheckClass::Informational, + Check::AutnumReserved => CheckClass::Informational, + + Check::VcardArrayIsEmpty => CheckClass::SpecificationError, + Check::VcardHasNoFn => CheckClass::SpecificationError, + Check::VcardFnIsEmpty => CheckClass::SpecificationWarning, + + Check::Port43IsEmpty => CheckClass::SpecificationError, + + Check::PublicIdTypeIsAbsent => CheckClass::SpecificationError, + Check::PublicIdIdentifierIsAbsent => CheckClass::SpecificationError, + + Check::Cidr0V4PrefixIsAbsent => CheckClass::SpecificationError, + Check::Cidr0V4LengthIsAbsent => CheckClass::SpecificationError, + Check::Cidr0V6PrefixIsAbsent => CheckClass::SpecificationError, + Check::Cidr0V6LengthIsAbsent => CheckClass::SpecificationError, + }; + CheckItem { + check_class, + check: self, + } + } } #[cfg(test)] @@ -277,7 +383,7 @@ mod tests { struct_name: "foo", items: vec![CheckItem { check_class: CheckClass::Informational, - check: Check::EmptyDomainVariant, + check: Check::VariantEmptyDomain, }], sub_checks: vec![], }; @@ -301,7 +407,7 @@ mod tests { struct_name: "foo", items: vec![CheckItem { check_class: CheckClass::SpecificationWarning, - check: Check::EmptyDomainVariant, + check: Check::VariantEmptyDomain, }], sub_checks: vec![], }; @@ -328,7 +434,7 @@ mod tests { struct_name: "bar", items: vec![CheckItem { check_class: CheckClass::Informational, - check: Check::EmptyDomainVariant, + check: Check::VariantEmptyDomain, }], sub_checks: vec![], }], @@ -356,7 +462,7 @@ mod tests { struct_name: "bar", items: vec![CheckItem { check_class: CheckClass::SpecificationWarning, - check: Check::EmptyDomainVariant, + check: Check::VariantEmptyDomain, }], sub_checks: vec![], }], @@ -381,13 +487,13 @@ mod tests { struct_name: "foo", items: vec![CheckItem { check_class: CheckClass::Informational, - check: Check::InvalidRdapConformanceParent, + check: Check::RdapConformanceInvalidParent, }], sub_checks: vec![Checks { struct_name: "bar", items: vec![CheckItem { check_class: CheckClass::Informational, - check: Check::EmptyDomainVariant, + check: Check::VariantEmptyDomain, }], sub_checks: vec![], }], diff --git a/icann-rdap-common/src/check/nameserver.rs b/icann-rdap-common/src/check/nameserver.rs index 22995bf..ce751ba 100644 --- a/icann-rdap-common/src/check/nameserver.rs +++ b/icann-rdap-common/src/check/nameserver.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use crate::response::nameserver::Nameserver; use super::string::StringListCheck; -use super::{string::StringCheck, CheckItem, CheckParams, Checks, GetChecks, GetSubChecks}; +use super::{string::StringCheck, Check, CheckParams, Checks, GetChecks, GetSubChecks}; impl GetChecks for Nameserver { fn get_checks(&self, params: CheckParams) -> super::Checks { @@ -28,25 +28,25 @@ impl GetChecks for Nameserver { // check ldh if let Some(ldh) = &self.ldh_name { if !ldh.is_ldh_domain_name() { - items.push(CheckItem::invalid_ldh_name()); + items.push(Check::LdhNameInvalid.check_item()); } } if let Some(ip_addresses) = &self.ip_addresses { if let Some(v6_addrs) = &ip_addresses.v6 { if v6_addrs.as_slice().is_empty_or_any_empty_or_whitespace() { - items.push(CheckItem::ip_address_list_is_empty()) + items.push(Check::IpAddressListIsEmpty.check_item()) } if v6_addrs.iter().any(|ip| IpAddr::from_str(ip).is_err()) { - items.push(CheckItem::malformed_ip_address()) + items.push(Check::IpAddressMalformed.check_item()) } } if let Some(v4_addrs) = &ip_addresses.v4 { if v4_addrs.as_slice().is_empty_or_any_empty_or_whitespace() { - items.push(CheckItem::ip_address_list_is_empty()) + items.push(Check::IpAddressListIsEmpty.check_item()) } if v4_addrs.iter().any(|ip| IpAddr::from_str(ip).is_err()) { - items.push(CheckItem::malformed_ip_address()) + items.push(Check::IpAddressMalformed.check_item()) } } } @@ -92,7 +92,7 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::InvalidLdhName)); + .any(|c| c.check == Check::LdhNameInvalid)); } #[test] @@ -170,7 +170,7 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::MalformedIpAddress)); + .any(|c| c.check == Check::IpAddressMalformed)); } #[test] @@ -196,6 +196,6 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::MalformedIpAddress)); + .any(|c| c.check == Check::IpAddressMalformed)); } } diff --git a/icann-rdap-common/src/check/network.rs b/icann-rdap-common/src/check/network.rs index e07aff2..7fe63aa 100644 --- a/icann-rdap-common/src/check/network.rs +++ b/icann-rdap-common/src/check/network.rs @@ -2,9 +2,9 @@ use std::{any::TypeId, net::IpAddr, str::FromStr}; use cidr_utils::cidr::IpCidr; -use crate::response::network::Network; +use crate::response::network::{Cidr0Cidr, Network}; -use super::{string::StringCheck, CheckItem, CheckParams, Checks, GetChecks, GetSubChecks}; +use super::{string::StringCheck, Check, CheckParams, Checks, GetChecks, GetSubChecks}; impl GetChecks for Network { fn get_checks(&self, params: CheckParams) -> super::Checks { @@ -17,6 +17,42 @@ impl GetChecks for Network { .object_common .get_sub_checks(params.from_parent(TypeId::of::())), ); + if let Some(cidr0) = &self.cidr0_cidrs { + cidr0.iter().for_each(|cidr| match cidr { + Cidr0Cidr::V4Cidr(v4) => { + if v4.v4prefix.is_none() { + sub_checks.push(Checks { + struct_name: "Cidr0", + items: vec![Check::Cidr0V4PrefixIsAbsent.check_item()], + sub_checks: Vec::new(), + }) + } + if v4.length.is_none() { + sub_checks.push(Checks { + struct_name: "Cidr0", + items: vec![Check::Cidr0V4LengthIsAbsent.check_item()], + sub_checks: Vec::new(), + }) + } + } + Cidr0Cidr::V6Cidr(v6) => { + if v6.v6prefix.is_none() { + sub_checks.push(Checks { + struct_name: "Cidr0", + items: vec![Check::Cidr0V6PrefixIsAbsent.check_item()], + sub_checks: Vec::new(), + }) + } + if v6.length.is_none() { + sub_checks.push(Checks { + struct_name: "Cidr0", + items: vec![Check::Cidr0V6LengthIsAbsent.check_item()], + sub_checks: Vec::new(), + }) + } + } + }) + } sub_checks } else { Vec::new() @@ -26,24 +62,24 @@ impl GetChecks for Network { if let Some(name) = &self.name { if name.is_whitespace_or_empty() { - items.push(CheckItem::name_is_empty()) + items.push(Check::NetworkOrAutnumNameIsEmpty.check_item()) } } if let Some(network_type) = &self.network_type { if network_type.is_whitespace_or_empty() { - items.push(CheckItem::type_is_empty()) + items.push(Check::NetworkOrAutnumTypeIsEmpty.check_item()) } } if self.start_address.is_none() || self.end_address.is_none() { - items.push(CheckItem::missing_ip_address()) + items.push(Check::IpAddressMissing.check_item()) } if let Some(start_ip) = &self.start_address { let start_addr = IpAddr::from_str(start_ip); if start_addr.is_err() { - items.push(CheckItem::malformed_ip_address()) + items.push(Check::IpAddressMalformed.check_item()) } else if self.end_address.is_some() { let Ok(start_addr) = start_addr else { panic!("ip result did not work") @@ -53,21 +89,21 @@ impl GetChecks for Network { }; if let Ok(end_addr) = IpAddr::from_str(end_ip) { if start_addr > end_addr { - items.push(CheckItem::end_ip_before_start_ip()) + items.push(Check::IpAddressEndBeforeStart.check_item()) } if let Some(ip_version) = &self.ip_version { if (ip_version == "v4" && (start_addr.is_ipv6() || end_addr.is_ipv6())) || (ip_version == "v6" && (start_addr.is_ipv4() || end_addr.is_ipv4())) { - items.push(CheckItem::ip_version_mismatch()) + items.push(Check::IpAddressVersionMismatch.check_item()) } else if ip_version != "v4" && ip_version != "v6" { - items.push(CheckItem::malfomred_ip_version()) + items.push(Check::IpAddressMalformedVersion.check_item()) } } let this_network = IpCidr::from_str("0.0.0.0/8").expect("incorrect this netowrk cidr"); if this_network.contains(&start_addr) && this_network.contains(&end_addr) { - items.push(CheckItem::this_network()) + items.push(Check::IpAddressThisNetwork.check_item()) } let private_10 = IpCidr::from_str("10.0.0.0/8").expect("incorrect net 10 cidr"); let private_172 = @@ -78,17 +114,17 @@ impl GetChecks for Network { || (private_172.contains(&start_addr) && private_172.contains(&end_addr)) || (private_192.contains(&start_addr) && private_192.contains(&end_addr)) { - items.push(CheckItem::private_use_ip()) + items.push(Check::IpAddressPrivateUse.check_item()) } let shared_nat = IpCidr::from_str("100.64.0.0/10").expect("incorrect net 100 cidr"); if shared_nat.contains(&start_addr) && shared_nat.contains(&end_addr) { - items.push(CheckItem::shared_nat_ip()) + items.push(Check::IpAddressSharedNat.check_item()) } let loopback = IpCidr::from_str("127.0.0.0/8").expect("incorrect loopback cidr"); if loopback.contains(&start_addr) && loopback.contains(&end_addr) { - items.push(CheckItem::loopback()) + items.push(Check::IpAddressLoopback.check_item()) } let linklocal1 = IpCidr::from_str("169.254.0.0/16").expect("incorrect linklocal1 cidr"); @@ -97,12 +133,12 @@ impl GetChecks for Network { if (linklocal1.contains(&start_addr) && linklocal1.contains(&end_addr)) || (linklocal2.contains(&start_addr) && linklocal2.contains(&end_addr)) { - items.push(CheckItem::linklocal()) + items.push(Check::IpAddressLinkLocal.check_item()) } let uniquelocal = IpCidr::from_str("fe80::/10").expect("incorrect unique local cidr"); if uniquelocal.contains(&start_addr) && uniquelocal.contains(&end_addr) { - items.push(CheckItem::unique_local()) + items.push(Check::IpAddressUniqueLocal.check_item()) } let doc1 = IpCidr::from_str("192.0.2.0/24").expect("incorrect doc1 cidr"); let doc2 = IpCidr::from_str("198.51.100.0/24").expect("incorrect doc2 cidr"); @@ -113,12 +149,12 @@ impl GetChecks for Network { || (doc3.contains(&start_addr) && doc3.contains(&end_addr)) || (doc4.contains(&start_addr) && doc4.contains(&end_addr)) { - items.push(CheckItem::documentation_net()) + items.push(Check::IpAddressDocumentationNet.check_item()) } let reserved = IpCidr::from_str("240.0.0.0/4").expect("incorrect reserved cidr"); if reserved.contains(&start_addr) && reserved.contains(&end_addr) { - items.push(CheckItem::linklocal()) + items.push(Check::IpAddressLinkLocal.check_item()) } } } @@ -127,7 +163,7 @@ impl GetChecks for Network { if let Some(end_ip) = &self.end_address { let addr = IpAddr::from_str(end_ip); if addr.is_err() { - items.push(CheckItem::malformed_ip_address()) + items.push(Check::IpAddressMalformed.check_item()) } } @@ -145,7 +181,8 @@ mod tests { use rstest::rstest; - use crate::response::network::Network; + use crate::response::network::{Cidr0Cidr, Network, V4Cidr, V6Cidr}; + use crate::response::types::{Common, ObjectCommon}; use crate::response::RdapResponse; use crate::check::{Check, CheckParams, GetChecks}; @@ -169,7 +206,10 @@ mod tests { // THEN dbg!(&checks); - assert!(checks.items.iter().any(|c| c.check == Check::NameIsEmpty)); + assert!(checks + .items + .iter() + .any(|c| c.check == Check::NetworkOrAutnumNameIsEmpty)); } #[test] @@ -191,7 +231,10 @@ mod tests { // THEN dbg!(&checks); - assert!(checks.items.iter().any(|c| c.check == Check::TypeIsEmpty)); + assert!(checks + .items + .iter() + .any(|c| c.check == Check::NetworkOrAutnumTypeIsEmpty)); } #[test] @@ -216,7 +259,7 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::MissingIpAddress)); + .any(|c| c.check == Check::IpAddressMissing)); } #[test] @@ -241,7 +284,7 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::MissingIpAddress)); + .any(|c| c.check == Check::IpAddressMissing)); } #[test] @@ -266,7 +309,7 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::MalformedIpAddress)); + .any(|c| c.check == Check::IpAddressMalformed)); } #[test] @@ -291,7 +334,7 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::MalformedIpAddress)); + .any(|c| c.check == Check::IpAddressMalformed)); } #[test] @@ -318,7 +361,7 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::EndIpBeforeStartIp)); + .any(|c| c.check == Check::IpAddressEndBeforeStart)); } #[rstest] @@ -348,7 +391,7 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::IpVersionMismatch)); + .any(|c| c.check == Check::IpAddressVersionMismatch)); } #[rstest] @@ -380,6 +423,126 @@ mod tests { assert!(checks .items .iter() - .any(|c| c.check == Check::MalformedIPVersion)); + .any(|c| c.check == Check::IpAddressMalformedVersion)); + } + + #[test] + fn GIVEN_cidr0_with_v4_prefixex_WHEN_checked_THEN_no_prefix_check() { + // GIVEN + let network = Network::builder() + .cidr0_cidrs(vec![Cidr0Cidr::V4Cidr(V4Cidr { + v4prefix: None, + length: Some(0), + })]) + .common(Common::builder().build()) + .object_common(ObjectCommon::ip_network().build()) + .build(); + let rdap = RdapResponse::Network(network); + + // WHEN + let checks = rdap.get_checks(CheckParams { + do_subchecks: true, + root: &rdap, + parent_type: rdap.get_type(), + }); + + // THEN + dbg!(&checks); + assert!(checks + .sub("Cidr0") + .expect("Cidr0") + .items + .iter() + .any(|c| c.check == Check::Cidr0V4PrefixIsAbsent)); + } + + #[test] + fn GIVEN_cidr0_with_v6_prefixex_WHEN_checked_THEN_no_prefix_check() { + // GIVEN + let network = Network::builder() + .cidr0_cidrs(vec![Cidr0Cidr::V6Cidr(V6Cidr { + v6prefix: None, + length: Some(0), + })]) + .common(Common::builder().build()) + .object_common(ObjectCommon::ip_network().build()) + .build(); + let rdap = RdapResponse::Network(network); + + // WHEN + let checks = rdap.get_checks(CheckParams { + do_subchecks: true, + root: &rdap, + parent_type: rdap.get_type(), + }); + + // THEN + dbg!(&checks); + assert!(checks + .sub("Cidr0") + .expect("Cidr0") + .items + .iter() + .any(|c| c.check == Check::Cidr0V6PrefixIsAbsent)); + } + + #[test] + fn GIVEN_cidr0_with_v4_length_WHEN_checked_THEN_no_length_check() { + // GIVEN + let network = Network::builder() + .cidr0_cidrs(vec![Cidr0Cidr::V4Cidr(V4Cidr { + v4prefix: Some("0.0.0.0".to_string()), + length: None, + })]) + .common(Common::builder().build()) + .object_common(ObjectCommon::ip_network().build()) + .build(); + let rdap = RdapResponse::Network(network); + + // WHEN + let checks = rdap.get_checks(CheckParams { + do_subchecks: true, + root: &rdap, + parent_type: rdap.get_type(), + }); + + // THEN + dbg!(&checks); + assert!(checks + .sub("Cidr0") + .expect("Cidr0") + .items + .iter() + .any(|c| c.check == Check::Cidr0V4LengthIsAbsent)); + } + + #[test] + fn GIVEN_cidr0_with_v6_length_WHEN_checked_THEN_no_length_check() { + // GIVEN + let network = Network::builder() + .cidr0_cidrs(vec![Cidr0Cidr::V6Cidr(V6Cidr { + v6prefix: Some("0.0.0.0".to_string()), + length: None, + })]) + .common(Common::builder().build()) + .object_common(ObjectCommon::ip_network().build()) + .build(); + let rdap = RdapResponse::Network(network); + + // WHEN + let checks = rdap.get_checks(CheckParams { + do_subchecks: true, + root: &rdap, + parent_type: rdap.get_type(), + }); + + // THEN + dbg!(&checks); + assert!(checks + .sub("Cidr0") + .expect("Cidr0") + .items + .iter() + .any(|c| c.check == Check::Cidr0V6LengthIsAbsent)); } } diff --git a/icann-rdap-common/src/check/types.rs b/icann-rdap-common/src/check/types.rs index 735e780..61d30d7 100644 --- a/icann-rdap-common/src/check/types.rs +++ b/icann-rdap-common/src/check/types.rs @@ -9,7 +9,8 @@ use crate::{ nameserver::Nameserver, network::Network, types::{ - Common, Link, Links, NoticeOrRemark, Notices, ObjectCommon, RdapConformance, Remarks, + Common, Link, Links, NoticeOrRemark, Notices, ObjectCommon, PublicIds, RdapConformance, + Remarks, }, }, }; @@ -18,14 +19,14 @@ use lazy_static::lazy_static; use super::{ string::{StringCheck, StringListCheck}, - CheckItem, CheckParams, Checks, GetChecks, GetSubChecks, + Check, CheckItem, CheckParams, Checks, GetChecks, GetSubChecks, }; impl GetChecks for RdapConformance { fn get_checks(&self, params: CheckParams) -> Checks { let mut items = Vec::new(); if params.parent_type != params.root.get_type() { - items.push(CheckItem::invalid_rdap_conformance_parent()) + items.push(Check::RdapConformanceInvalidParent.check_item()) }; Checks { struct_name: "RDAP Conformance", @@ -63,7 +64,10 @@ impl GetChecks for Link { fn get_checks(&self, params: CheckParams) -> Checks { let mut items: Vec = Vec::new(); if self.value.is_none() { - items.push(CheckItem::link_missing_value_property()) + items.push(Check::LinkMissingValueProperty.check_item()) + }; + if self.href.is_none() { + items.push(Check::LinkMissingHrefProperty.check_item()) }; if let Some(rel) = &self.rel { if rel.eq("related") { @@ -71,18 +75,18 @@ impl GetChecks for Link { if !media_type.eq(RDAP_MEDIA_TYPE) && RELATED_AND_SELF_LINK_PARENTS.contains(¶ms.parent_type) { - items.push(CheckItem::related_link_is_not_rdap()) + items.push(Check::LinkRelatedIsNotRdap.check_item()) } } else { - items.push(CheckItem::related_link_has_no_type()) + items.push(Check::LinkRelatedHasNoType.check_item()) } } else if rel.eq("self") { if let Some(media_type) = &self.media_type { if !media_type.eq(RDAP_MEDIA_TYPE) { - items.push(CheckItem::self_link_is_not_rdap()) + items.push(Check::LinkSelfIsNotRdap.check_item()) } } else { - items.push(CheckItem::self_link_has_no_type()) + items.push(Check::LinkSelfHasNoType.check_item()) } } else if RELATED_AND_SELF_LINK_PARENTS.contains(¶ms.parent_type) && // because some registries do not model nameservers directly, @@ -92,10 +96,10 @@ impl GetChecks for Link { // the top most object (i.e. a first class object). params.root.get_type() != TypeId::of::() { - items.push(CheckItem::object_class_has_no_self_link()) + items.push(Check::LinkObjectClassHasNoSelf.check_item()) } } else { - items.push(CheckItem::link_missing_rel_property()) + items.push(Check::LinkMissingRelProperty.check_item()) } Checks { struct_name: "Link", @@ -137,6 +141,10 @@ impl GetChecks for Remarks { impl GetChecks for NoticeOrRemark { fn get_checks(&self, params: CheckParams) -> Checks { + let mut items: Vec = Vec::new(); + if self.description.is_none() { + items.push(Check::NoticeOrRemarkDescriptionIsAbsent.check_item()) + }; let mut sub_checks: Vec = Vec::new(); if params.do_subchecks { if let Some(links) = &self.links { @@ -148,12 +156,35 @@ impl GetChecks for NoticeOrRemark { }; Checks { struct_name: "Notice/Remark", - items: Vec::new(), + items, sub_checks, } } } +impl GetSubChecks for PublicIds { + fn get_sub_checks(&self, _params: CheckParams) -> Vec { + let mut sub_checks: Vec = Vec::new(); + self.iter().for_each(|pid| { + if pid.id_type.is_none() { + sub_checks.push(Checks { + struct_name: "Public IDs", + items: vec![Check::PublicIdTypeIsAbsent.check_item()], + sub_checks: Vec::new(), + }); + } + if pid.identifier.is_none() { + sub_checks.push(Checks { + struct_name: "Public IDs", + items: vec![Check::PublicIdIdentifierIsAbsent.check_item()], + sub_checks: Vec::new(), + }); + } + }); + sub_checks + } +} + impl GetSubChecks for Common { fn get_sub_checks(&self, params: CheckParams) -> Vec { let mut sub_checks: Vec = Vec::new(); @@ -195,7 +226,7 @@ impl GetSubChecks for ObjectCommon { { sub_checks.push(Checks { struct_name: "Links", - items: vec![CheckItem::object_class_has_no_self_link()], + items: vec![Check::LinkObjectClassHasNoSelf.check_item()], sub_checks: Vec::new(), }) }; @@ -213,14 +244,21 @@ impl GetSubChecks for ObjectCommon { if date.is_err() { sub_checks.push(Checks { struct_name: "Events", - items: vec![CheckItem::event_date_is_not_rfc3339()], + items: vec![Check::EventDateIsNotRfc3339.check_item()], sub_checks: Vec::new(), }) } } else { sub_checks.push(Checks { struct_name: "Events", - items: vec![CheckItem::event_date_is_absent()], + items: vec![Check::EventDateIsAbsent.check_item()], + sub_checks: Vec::new(), + }) + } + if e.event_action.is_none() { + sub_checks.push(Checks { + struct_name: "Events", + items: vec![Check::EventActionIsAbsent.check_item()], sub_checks: Vec::new(), }) } @@ -232,7 +270,7 @@ impl GetSubChecks for ObjectCommon { if handle.is_whitespace_or_empty() { sub_checks.push(Checks { struct_name: "Handle", - items: vec![CheckItem::handle_is_empty()], + items: vec![Check::HandleIsEmpty.check_item()], sub_checks: Vec::new(), }) } @@ -244,17 +282,18 @@ impl GetSubChecks for ObjectCommon { if status.as_slice().is_empty_or_any_empty_or_whitespace() { sub_checks.push(Checks { struct_name: "Status", - items: vec![CheckItem::status_is_empty()], + items: vec![Check::StatusIsEmpty.check_item()], sub_checks: Vec::new(), }) } } + // Port 43 if let Some(port43) = &self.port_43 { if port43.is_whitespace_or_empty() { sub_checks.push(Checks { struct_name: "Port43", - items: vec![CheckItem::port43_is_empty()], + items: vec![Check::Port43IsEmpty.check_item()], sub_checks: Vec::new(), }) } @@ -276,8 +315,8 @@ mod tests { entity::Entity, nameserver::Nameserver, types::{ - Common, Event, Extension, Link, Notice, NoticeOrRemark, ObjectCommon, Remark, - StatusValue, + Common, Event, Extension, Link, Notice, NoticeOrRemark, ObjectCommon, PublicId, + Remark, StatusValue, }, RdapResponse, }, @@ -293,7 +332,15 @@ mod tests { .common(Common::builder().build()) .object_common( ObjectCommon::domain() - .links(vec![Link::builder().href("https://foo").build()]) + .links(vec![Link { + href: Some("https://foo".to_string()), + value: Some("https://foo".to_string()), + rel: None, + title: None, + hreflang: None, + media: None, + media_type: None, + }]) .build(), ) .build(), @@ -326,7 +373,15 @@ mod tests { .common(Common::builder().build()) .object_common( ObjectCommon::domain() - .links(vec![Link::builder().href("https://foo").build()]) + .links(vec![Link { + href: Some("https://foo".to_string()), + value: None, + rel: Some("about".to_string()), + title: None, + hreflang: None, + media: None, + media_type: None, + }]) .build(), ) .build(), @@ -351,6 +406,47 @@ mod tests { .expect("link missing check"); } + #[test] + fn GIVEN_link_with_no_href_property_WHEN_checked_THEN_link_missing_href_property() { + // GIVEN + let rdap = RdapResponse::Domain( + Domain::builder() + .common(Common::builder().build()) + .object_common( + ObjectCommon::domain() + .links(vec![Link { + value: Some("https://foo".to_string()), + href: None, + rel: Some("about".to_string()), + title: None, + hreflang: None, + media: None, + media_type: None, + }]) + .build(), + ) + .build(), + ); + + // WHEN + let checks = rdap.get_checks(CheckParams { + do_subchecks: true, + root: &rdap, + parent_type: rdap.get_type(), + }); + + // THEN + checks + .sub("Links") + .expect("Links not found") + .sub("Link") + .expect("Link not found") + .items + .iter() + .find(|c| c.check == Check::LinkMissingHrefProperty) + .expect("link missing check"); + } + #[test] fn GIVEN_related_link_with_no_type_property_WHEN_checked_THEN_related_link_has_no_type() { // GIVEN @@ -361,6 +457,7 @@ mod tests { ObjectCommon::domain() .links(vec![Link::builder() .href("https://foo") + .value("https://foo") .rel("related") .build()]) .build(), @@ -383,7 +480,7 @@ mod tests { .expect("Link not found") .items .iter() - .find(|c| c.check == Check::RelatedLinkHasNoType) + .find(|c| c.check == Check::LinkRelatedHasNoType) .expect("link missing check"); } @@ -397,6 +494,7 @@ mod tests { ObjectCommon::domain() .links(vec![Link::builder() .href("https://foo") + .value("https://foo") .rel("related") .media_type("foo") .build()]) @@ -420,7 +518,7 @@ mod tests { .expect("Link not found") .items .iter() - .find(|c| c.check == Check::RelatedLinkIsNotRdap) + .find(|c| c.check == Check::LinkRelatedIsNotRdap) .expect("link missing check"); } @@ -434,6 +532,7 @@ mod tests { ObjectCommon::domain() .links(vec![Link::builder() .href("https://foo") + .value("https://foo") .rel("self") .build()]) .build(), @@ -456,7 +555,7 @@ mod tests { .expect("Link not found") .items .iter() - .find(|c| c.check == Check::SelfLinkHasNoType) + .find(|c| c.check == Check::LinkSelfHasNoType) .expect("link missing check"); } @@ -470,6 +569,7 @@ mod tests { ObjectCommon::domain() .links(vec![Link::builder() .href("https://foo") + .value("https://foo") .rel("self") .media_type("foo") .build()]) @@ -486,7 +586,7 @@ mod tests { }); // THEN - assert!(find_any_check(&checks, Check::SelfLinkIsNotRdap)); + assert!(find_any_check(&checks, Check::LinkSelfIsNotRdap)); } #[test] @@ -499,6 +599,7 @@ mod tests { ObjectCommon::domain() .links(vec![Link::builder() .href("https://foo") + .value("https://foo") .rel("self") .media_type("application/rdap+json") .build()]) @@ -516,7 +617,7 @@ mod tests { // THEN dbg!(&checks); - assert!(!find_any_check(&checks, Check::ObjectClassHasNoSelfLink)); + assert!(!find_any_check(&checks, Check::LinkObjectClassHasNoSelf)); } #[test] @@ -529,6 +630,7 @@ mod tests { ObjectCommon::domain() .links(vec![Link::builder() .href("https://foo") + .value("https://foo") .rel("self") .media_type("application/rdap+json") .build()]) @@ -545,7 +647,7 @@ mod tests { }); // THEN - assert!(!find_any_check(&checks, Check::ObjectClassHasNoSelfLink)); + assert!(!find_any_check(&checks, Check::LinkObjectClassHasNoSelf)); } #[test] @@ -561,6 +663,7 @@ mod tests { .description_entry("a notice") .links(vec![Link::builder() .href("https://tos") + .value("https://tos") .rel("terms-of-service") .media_type("text/html") .build()]) @@ -572,6 +675,7 @@ mod tests { ObjectCommon::domain() .links(vec![Link::builder() .href("https://foo") + .value("https://foo") .rel("self") .media_type("application/rdap+json") .build()]) @@ -589,7 +693,7 @@ mod tests { // THEN dbg!(&checks); - assert!(!find_any_check(&checks, Check::ObjectClassHasNoSelfLink)); + assert!(!find_any_check(&checks, Check::LinkObjectClassHasNoSelf)); } #[test] @@ -606,6 +710,7 @@ mod tests { .description_entry("a notice") .links(vec![Link::builder() .href("https://tos") + .value("https://tos") .rel("terms-of-service") .media_type("text/html") .build()]) @@ -613,6 +718,7 @@ mod tests { )]) .links(vec![Link::builder() .href("https://foo") + .value("https://foo") .rel("self") .media_type("application/rdap+json") .build()]) @@ -630,7 +736,7 @@ mod tests { // THEN dbg!(&checks); - assert!(!find_any_check(&checks, Check::ObjectClassHasNoSelfLink)); + assert!(!find_any_check(&checks, Check::LinkObjectClassHasNoSelf)); } #[test] @@ -643,6 +749,7 @@ mod tests { ObjectCommon::domain() .links(vec![Link::builder() .href("https://foo") + .value("https://foo") .rel("no_self") .media_type("foo") .build()]) @@ -666,7 +773,7 @@ mod tests { .expect("Link not found") .items .iter() - .find(|c| c.check == Check::ObjectClassHasNoSelfLink) + .find(|c| c.check == Check::LinkObjectClassHasNoSelf) .expect("link missing check"); } @@ -693,7 +800,7 @@ mod tests { .expect("Links not found") .items .iter() - .find(|c| c.check == Check::ObjectClassHasNoSelfLink) + .find(|c| c.check == Check::LinkObjectClassHasNoSelf) .expect("link missing check"); } @@ -705,7 +812,12 @@ mod tests { .common(Common::builder().build()) .object_common( ObjectCommon::domain() - .events(vec![Event::builder().event_action("foo").build()]) + .events(vec![Event { + event_action: Some("foo".to_string()), + event_date: None, + event_actor: None, + links: None, + }]) .build(), ) .build(), @@ -728,6 +840,42 @@ mod tests { .expect("event missing check"); } + #[test] + fn GIVEN_event_with_no_action_WHEN_checked_THEN_event_action_absent() { + // GIVEN + let rdap = RdapResponse::Domain( + Domain::builder() + .common(Common::builder().build()) + .object_common( + ObjectCommon::domain() + .events(vec![Event { + event_date: Some("1990-12-31T23:59:59Z".to_string()), + event_action: None, + event_actor: None, + links: None, + }]) + .build(), + ) + .build(), + ); + + // WHEN + let checks = rdap.get_checks(CheckParams { + do_subchecks: true, + root: &rdap, + parent_type: rdap.get_type(), + }); + + // THEN + checks + .sub("Events") + .expect("Events not found") + .items + .iter() + .find(|c| c.check == Check::EventActionIsAbsent) + .expect("event missing check"); + } + #[test] fn GIVEN_event_with_bad_date_WHEN_checked_THEN_event_date_is_not_date() { // GIVEN @@ -762,6 +910,103 @@ mod tests { .expect("event missing check"); } + #[test] + fn GIVEN_public_id_with_no_type_WHEN_checked_THEN_type_is_absent() { + // GIVEN + let rdap = RdapResponse::Domain( + Domain::builder() + .common(Common::builder().build()) + .object_common(ObjectCommon::domain().build()) + .public_ids(vec![PublicId { + id_type: None, + identifier: Some("thing".to_string()), + }]) + .build(), + ); + + // WHEN + let checks = rdap.get_checks(CheckParams { + do_subchecks: true, + root: &rdap, + parent_type: rdap.get_type(), + }); + + // THEN + checks + .sub("Public IDs") + .expect("Public Ids not found") + .items + .iter() + .find(|c| c.check == Check::PublicIdTypeIsAbsent) + .expect("public id missing check"); + } + + #[test] + fn GIVEN_public_id_with_no_identifier_WHEN_checked_THEN_identifier_is_absent() { + // GIVEN + let rdap = RdapResponse::Domain( + Domain::builder() + .common(Common::builder().build()) + .object_common(ObjectCommon::domain().build()) + .public_ids(vec![PublicId { + identifier: None, + id_type: Some("thing".to_string()), + }]) + .build(), + ); + + // WHEN + let checks = rdap.get_checks(CheckParams { + do_subchecks: true, + root: &rdap, + parent_type: rdap.get_type(), + }); + + // THEN + checks + .sub("Public IDs") + .expect("Public Ids not found") + .items + .iter() + .find(|c| c.check == Check::PublicIdIdentifierIsAbsent) + .expect("public id missing check"); + } + + #[test] + fn GIVEN_notice_with_no_description_WHEN_checked_THEN_description_absent() { + // GIVEN + let notice = NoticeOrRemark { + title: None, + description: None, + links: None, + }; + let rdap = RdapResponse::Domain( + Domain::builder() + .common(Common::builder().notices(vec![Notice(notice)]).build()) + .object_common(ObjectCommon::domain().build()) + .build(), + ); + + // WHEN + let checks = rdap.get_checks(CheckParams { + do_subchecks: true, + root: &rdap, + parent_type: rdap.get_type(), + }); + + // THEN + dbg!(&checks); + checks + .sub("Notices") + .expect("Notices not found") + .sub("Notice/Remark") + .expect("Notice/Remark not found") + .items + .iter() + .find(|c| c.check == Check::NoticeOrRemarkDescriptionIsAbsent) + .expect("description missing check"); + } + #[test] fn GIVEN_nameserver_with_no_links_WHEN_checked_THEN_no_object_classes_should_have_self_link() { // GIVEN @@ -794,6 +1039,7 @@ mod tests { ObjectCommon::nameserver() .links(vec![Link::builder() .href("https://foo") + .value("https://foo") .rel("no_self") .media_type("foo") .build()]) @@ -815,7 +1061,7 @@ mod tests { .expect("Links not found") .items .iter() - .any(|c| c.check == Check::ObjectClassHasNoSelfLink)); + .any(|c| c.check == Check::LinkObjectClassHasNoSelf)); } #[rstest] @@ -920,7 +1166,7 @@ mod tests { .expect("rdap conformance not found") .items .iter() - .find(|c| c.check == Check::InvalidRdapConformanceParent) + .find(|c| c.check == Check::RdapConformanceInvalidParent) .expect("check missing"); } diff --git a/icann-rdap-common/src/client.rs b/icann-rdap-common/src/client.rs index cf31473..30cd1d1 100644 --- a/icann-rdap-common/src/client.rs +++ b/icann-rdap-common/src/client.rs @@ -1,3 +1,5 @@ +//! Creates a Reqwest client. + use lazy_static::lazy_static; use reqwest::{ header::{self, HeaderValue}, diff --git a/icann-rdap-common/src/contact/from_vcard.rs b/icann-rdap-common/src/contact/from_vcard.rs index ebba8e2..450c6b1 100644 --- a/icann-rdap-common/src/contact/from_vcard.rs +++ b/icann-rdap-common/src/contact/from_vcard.rs @@ -1,30 +1,70 @@ +//! Convert jCard/vCard to Contact. use serde_json::Value; use super::{Contact, Email, Lang, NameParts, Phone, PostalAddress}; impl Contact { + /// Creates a Contact from an array of [`Value`]s. + /// + /// ```rust + /// use icann_rdap_common::contact::Contact; + /// use serde::Deserialize; + /// use serde_json::Value; + /// + /// let json = r#" + /// [ + /// "vcard", + /// [ + /// ["version", {}, "text", "4.0"], + /// ["fn", {}, "text", "Joe User"], + /// ["kind", {}, "text", "individual"], + /// ["org", { + /// "type":"work" + /// }, "text", "Example"], + /// ["title", {}, "text", "Research Scientist"], + /// ["role", {}, "text", "Project Lead"], + /// ["adr", + /// { "type":"work" }, + /// "text", + /// [ + /// "", + /// "Suite 1234", + /// "4321 Rue Somewhere", + /// "Quebec", + /// "QC", + /// "G1V 2M2", + /// "Canada" + /// ] + /// ], + /// ["tel", + /// { "type":["work", "voice"], "pref":"1" }, + /// "uri", "tel:+1-555-555-1234;ext=102" + /// ], + /// ["email", + /// { "type":"work" }, + /// "text", "joe.user@example.com" + /// ] + /// ] + /// ]"#; + /// + /// let data: Vec = serde_json::from_str(json).unwrap(); + /// let contact = Contact::from_vcard(&data); + /// ``` pub fn from_vcard(vcard_array: &[Value]) -> Option { // value should be "vcard" followed by array - let Some(value) = vcard_array.first() else { - return None; - }; - let Some(vcard_literal) = value.as_str() else { - return None; - }; + let value = vcard_array.first()?; + let vcard_literal = value.as_str()?; if !vcard_literal.eq_ignore_ascii_case("vcard") { return None; }; - let Some(vcard) = vcard_array.get(1) else { - return None; - }; - let Some(vcard) = vcard.as_array() else { - return None; - }; + let vcard = vcard_array.get(1)?; + let vcard = vcard.as_array()?; let contact = Contact::builder() .and_full_name(vcard.find_property("fn").get_text()) .and_kind(vcard.find_property("kind").get_text()) .and_titles(vcard.find_properties("title").get_texts()) + .and_roles(vcard.find_properties("role").get_texts()) .and_nick_names(vcard.find_properties("nickname").get_texts()) .and_organization_names(vcard.find_properties("org").get_texts()) .and_langs(vcard.find_properties("lang").get_langs()) @@ -32,6 +72,8 @@ impl Contact { .and_phones(vcard.find_properties("tel").get_phones()) .and_postal_addresses(vcard.find_properties("adr").get_postal_addresses()) .and_name_parts(vcard.find_property("n").get_name_parts()) + .and_contact_uris(vcard.find_properties("contact-uri").get_texts()) + .and_urls(vcard.find_properties("url").get_texts()) .build(); contact.is_non_empty().then_some(contact) @@ -90,18 +132,14 @@ trait GetText<'a> { impl<'a> GetText<'a> for Option<&'a Vec> { fn get_text(self) -> Option { let values = self?; - let Some(fourth) = values.get(3) else { - return None; - }; + let fourth = values.get(3)?; fourth.as_str().map(|s| s.to_owned()) } } impl<'a> GetText<'a> for &'a Vec { fn get_text(self) -> Option { - let Some(fourth) = self.get(3) else { - return None; - }; + let fourth = self.get(3)?; fourth.as_str().map(|s| s.to_owned()) } } @@ -126,15 +164,9 @@ trait GetPreference<'a> { impl<'a> GetPreference<'a> for &'a Vec { fn get_preference(self) -> Option { - let Some(second) = self.get(1) else { - return None; - }; - let Some(second) = second.as_object() else { - return None; - }; - let Some(preference) = second.get("pref") else { - return None; - }; + let second = self.get(1)?; + let second = second.as_object()?; + let preference = second.get("pref")?; preference.as_str().and_then(|s| s.parse().ok()) } } @@ -145,15 +177,9 @@ trait GetLabel<'a> { impl<'a> GetLabel<'a> for &'a Vec { fn get_label(self) -> Option { - let Some(second) = self.get(1) else { - return None; - }; - let Some(second) = second.as_object() else { - return None; - }; - let Some(label) = second.get("label") else { - return None; - }; + let second = self.get(1)?; + let second = second.as_object()?; + let label = second.get("label")?; label.as_str().map(|s| s.to_owned()) } } @@ -166,15 +192,9 @@ trait GetContexts<'a> { impl<'a> GetContexts<'a> for &'a Vec { fn get_contexts(self) -> Option> { - let Some(second) = self.get(1) else { - return None; - }; - let Some(second) = second.as_object() else { - return None; - }; - let Some(contexts) = second.get("type") else { - return None; - }; + let second = self.get(1)?; + let second = second.as_object()?; + let contexts = second.get("type")?; if let Some(context) = contexts.as_str() { let context = context.to_lowercase(); if CONTEXTS.contains(&context.as_str()) { @@ -183,9 +203,7 @@ impl<'a> GetContexts<'a> for &'a Vec { return None; } }; - let Some(contexts) = contexts.as_array() else { - return None; - }; + let contexts = contexts.as_array()?; let contexts = contexts .iter() .filter_map(|v| v.as_str()) @@ -202,15 +220,9 @@ trait GetFeatures<'a> { impl<'a> GetFeatures<'a> for &'a Vec { fn get_features(self) -> Option> { - let Some(second) = self.get(1) else { - return None; - }; - let Some(second) = second.as_object() else { - return None; - }; - let Some(features) = second.get("type") else { - return None; - }; + let second = self.get(1)?; + let second = second.as_object()?; + let features = second.get("type")?; if let Some(feature) = features.as_str() { let feature = feature.to_lowercase(); if !CONTEXTS.contains(&feature.as_str()) { @@ -219,9 +231,7 @@ impl<'a> GetFeatures<'a> for &'a Vec { return None; } }; - let Some(features) = features.as_array() else { - return None; - }; + let features = features.as_array()?; let features = features .iter() .filter_map(|v| v.as_str()) @@ -241,9 +251,7 @@ impl<'a> GetLangs<'a> for &'a [&'a Vec] { let langs = self .iter() .filter_map(|prop| { - let Some(tag) = (*prop).get_text() else { - return None; - }; + let tag = (*prop).get_text()?; let lang = Lang::builder() .tag(tag) .and_preference((*prop).get_preference()) @@ -264,9 +272,7 @@ impl<'a> GetEmails<'a> for &'a [&'a Vec] { let emails = self .iter() .filter_map(|prop| { - let Some(addr) = (*prop).get_text() else { - return None; - }; + let addr = (*prop).get_text()?; let email = Email::builder() .email(addr) .and_contexts((*prop).get_contexts()) @@ -288,9 +294,7 @@ impl<'a> GetPhones<'a> for &'a [&'a Vec] { let phones = self .iter() .filter_map(|prop| { - let Some(number) = (*prop).get_text() else { - return None; - }; + let number = (*prop).get_text()?; let phone = Phone::builder() .phone(number) .and_features((*prop).get_features()) @@ -322,33 +326,84 @@ impl<'a> GetPostalAddresses<'a> for &'a [&'a Vec] { let mut street_parts: Vec = Vec::new(); if let Some(fourth) = prop.get(3) { if let Some(addr) = fourth.as_array() { - let mut iter = addr - .iter() - .rev() - .filter_map(|i| i.as_str()) - .filter(|i| !i.is_empty()); - if let Some(e) = iter.next() { - if e.len() == 2 && e.to_uppercase() == e { - country_code = Some(e.to_string()) - } else { - country_name = Some(e.to_string()) + // the jcard address fields are in a different index of the array. + // + // [ + // "adr", + // {}, + // "text", + // [ + // "Mail Stop 3", // post office box (not recommended for use) + // "Suite 3000", // apartment or suite (not recommended for use) + // "123 Maple Ave", // street address + // "Quebec", // locality or city name + // "QC", // region (can be either a code or full name) + // "G1V 2M2", // postal code + // "Canada" // full country name + // ] + // ], + if let Some(pobox) = addr.first() { + if let Some(s) = pobox.as_str() { + if !s.is_empty() { + street_parts.push(s.to_string()) + } + } + } + if let Some(appt) = addr.get(1) { + if let Some(s) = appt.as_str() { + if !s.is_empty() { + street_parts.push(s.to_string()) + } + } + } + if let Some(street) = addr.get(2) { + if let Some(s) = street.as_str() { + if !s.is_empty() { + street_parts.push(s.to_string()) + } + } else if let Some(arry_s) = street.as_array() { + arry_s + .iter() + .filter_map(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .for_each(|s| street_parts.push(s.to_string())) + } + } + if let Some(city) = addr.get(3) { + if let Some(s) = city.as_str() { + if !s.is_empty() { + locality = Some(s.to_string()); + } } - }; - if let Some(e) = iter.next() { - postal_code = Some(e.to_string()); - }; - if let Some(e) = iter.next() { - if e.len() == 2 && e.to_uppercase() == e { - region_code = Some(e.to_string()) - } else { - region_name = Some(e.to_string()) + } + if let Some(region) = addr.get(4) { + if let Some(s) = region.as_str() { + if !s.is_empty() { + if s.len() == 2 && s.to_uppercase() == s { + region_code = Some(s.to_string()) + } else { + region_name = Some(s.to_string()) + } + } + } + } + if let Some(pc) = addr.get(5) { + if let Some(s) = pc.as_str() { + if !s.is_empty() { + postal_code = Some(s.to_string()); + } + } + } + if let Some(country) = addr.get(6) { + if let Some(s) = country.as_str() { + if !s.is_empty() { + if s.len() == 2 && s.to_uppercase() == s { + country_code = Some(s.to_string()) + } else { + country_name = Some(s.to_string()) + } + } } - }; - if let Some(e) = iter.next() { - locality = Some(e.to_string()); - }; - for e in iter { - street_parts.insert(0, e.to_string()); } } }; @@ -378,12 +433,8 @@ trait GetNameParts<'a> { impl<'a> GetNameParts<'a> for Option<&'a Vec> { fn get_name_parts(self) -> Option { let values = self?; - let Some(fourth) = values.get(3) else { - return None; - }; - let Some(parts) = fourth.as_array() else { - return None; - }; + let fourth = values.get(3)?; + let parts = fourth.as_array()?; let mut iter = parts.iter().filter(|p| p.is_string() || p.is_array()); let mut prefixes: Option> = None; let mut surnames: Option> = None; @@ -527,8 +578,14 @@ mod tests { ], ["tz", {}, "utc-offset", "-05:00"], - ["url", { "type":"home" }, - "uri", "https://example.org"] + ["contact-uri", {}, + "uri", + "https://example.com/contact-form" + ], + ["url", {}, + "uri", + "https://example.com/some-url" + ] ] ] "#; @@ -556,6 +613,16 @@ mod tests { "Research Scientist" ); + // roles + assert_eq!( + actual + .roles + .expect("no roles") + .first() + .expect("roles empty"), + "Project Lead" + ); + // organization names assert_eq!( actual @@ -574,8 +641,8 @@ mod tests { panic!("langs not found") }; assert_eq!(langs.len(), 2); - assert_eq!(langs.get(0).expect("first lang").tag, "fr"); - assert_eq!(langs.get(0).expect("first lang").preference, Some(1)); + assert_eq!(langs.first().expect("first lang").tag, "fr"); + assert_eq!(langs.first().expect("first lang").preference, Some(1)); assert_eq!(langs.get(1).expect("second lang").tag, "en"); assert_eq!(langs.get(1).expect("second lang").preference, Some(2)); @@ -641,7 +708,7 @@ mod tests { let Some(street_parts) = &addr.street_parts else { panic!("no street parts") }; - assert_eq!(street_parts.get(0).expect("street part 0"), "Suite 1234"); + assert_eq!(street_parts.first().expect("street part 0"), "Suite 1234"); assert_eq!( street_parts.get(1).expect("street part 1"), "4321 Rue Somewhere" @@ -674,5 +741,86 @@ mod tests { .suffixes(vec!["ing. jr".to_string(), "M.Sc.".to_string()]) .build(); assert_eq!(name_parts, expected); + + // contact-uris + assert_eq!( + actual + .contact_uris + .expect("no contact-uris") + .first() + .expect("contact-uris empty"), + "https://example.com/contact-form" + ); + + // urls + assert_eq!( + actual + .urls + .expect("no urls") + .first() + .expect("urls are empty"), + "https://example.com/some-url" + ); + } + + #[test] + fn GIVEN_vcard_with_addr_street_array_WHEN_from_vcard_THEN_properties_are_correct() { + // GIVEN + let vcard = r#" + [ + "vcard", + [ + ["version", {}, "text", "4.0"], + ["fn", {}, "text", "Joe User"], + ["adr", + { "type":"work" }, + "text", + [ + "", + "Suite 1234", + ["4321 Rue Blue", "1, Gawwn"], + "Quebec", + "QC", + "G1V 2M2", + "Canada" + ] + ] + ] + ] + "#; + + // WHEN + let actual = serde_json::from_str::>(vcard); + + // THEN + let actual = actual.expect("parsing vcard"); + let actual = Contact::from_vcard(&actual).expect("vcard not found"); + + // full name + assert_eq!(actual.full_name.expect("full_name not found"), "Joe User"); + + // postal addresses + let Some(addresses) = actual.postal_addresses else { + panic!("no postal addresses") + }; + let Some(addr) = addresses.first() else { + panic!("first address not found") + }; + assert!(addr + .contexts + .as_ref() + .expect("no contexts") + .contains(&"work".to_string())); + let Some(street_parts) = &addr.street_parts else { + panic!("no street parts") + }; + assert_eq!(street_parts.first().expect("street part 0"), "Suite 1234"); + assert_eq!(street_parts.get(1).expect("street part 1"), "4321 Rue Blue"); + assert_eq!(street_parts.get(2).expect("street part 2"), "1, Gawwn"); + assert_eq!(addr.country_name.as_ref().expect("country name"), "Canada"); + assert!(addr.country_code.is_none()); + assert_eq!(addr.region_code.as_ref().expect("region code"), "QC"); + assert!(addr.region_name.is_none()); + assert_eq!(addr.postal_code.as_ref().expect("postal code"), "G1V 2M2"); } } diff --git a/icann-rdap-common/src/contact/mod.rs b/icann-rdap-common/src/contact/mod.rs index 2461ca2..a826e74 100644 --- a/icann-rdap-common/src/contact/mod.rs +++ b/icann-rdap-common/src/contact/mod.rs @@ -1,3 +1,84 @@ +//! Easy representation of contact information found in an Entity. +//! +//! This module converts contact information to and from vCard/jCard, which is hard to +//! work with directly. It is also intended as a way of bridging the between vCard/jCard +//! and any new contact model. +//! +//! This struct can be built using the builder. +//! +//! ```rust +//! use icann_rdap_common::contact::Contact; +//! +//! let contact = Contact::builder() +//! .kind("individual") +//! .full_name("Bob Smurd") +//! .build(); +//! ``` +//! +//! Once built, a Contact struct can be converted to an array of [serde_json::Value]'s, +//! which can be used with serde to serialize to JSON. +//! +//! ```rust +//! use icann_rdap_common::contact::Contact; +//! use serde::Serialize; +//! use serde_json::Value; +//! +//! let contact = Contact::builder() +//! .kind("individual") +//! .full_name("Bob Smurd") +//! .build(); +//! +//! let v = contact.to_vcard(); +//! let json = serde_json::to_string(&v); +//! ``` +//! +//! To deserialize, use the `from_vcard` function. +//! +//! ```rust +//! use icann_rdap_common::contact::Contact; +//! use serde::Deserialize; +//! use serde_json::Value; +//! +//! let json = r#" +//! [ +//! "vcard", +//! [ +//! ["version", {}, "text", "4.0"], +//! ["fn", {}, "text", "Joe User"], +//! ["kind", {}, "text", "individual"], +//! ["org", { +//! "type":"work" +//! }, "text", "Example"], +//! ["title", {}, "text", "Research Scientist"], +//! ["role", {}, "text", "Project Lead"], +//! ["adr", +//! { "type":"work" }, +//! "text", +//! [ +//! "", +//! "Suite 1234", +//! "4321 Rue Somewhere", +//! "Quebec", +//! "QC", +//! "G1V 2M2", +//! "Canada" +//! ] +//! ], +//! ["tel", +//! { "type":["work", "voice"], "pref":"1" }, +//! "uri", "tel:+1-555-555-1234;ext=102" +//! ], +//! ["email", +//! { "type":"work" }, +//! "text", "joe.user@example.com" +//! ] +//! ] +//! ]"#; +//! +//! let data: Vec = serde_json::from_str(json).unwrap(); +//! let contact = Contact::from_vcard(&data); +//! ``` + pub mod from_vcard; pub mod to_vcard; @@ -17,6 +98,8 @@ use buildstructor::Builder; /// .full_name("Bob Smurd") /// .build(); /// ``` +/// +/// #[derive(Debug, Builder, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct Contact { /// Preferred languages. @@ -37,6 +120,9 @@ pub struct Contact { /// Titles. pub titles: Option>, + /// Organizational Roles + pub roles: Option>, + /// Organization names. pub organization_names: Option>, @@ -48,9 +134,16 @@ pub struct Contact { /// Phone numbers. pub phones: Option>, + + /// Contact URIs. + pub contact_uris: Option>, + + /// URLs + pub urls: Option>, } impl Contact { + /// Returns false if there is data in the Contact. pub fn is_non_empty(&self) -> bool { self.langs.is_some() || self.kind.is_some() @@ -58,12 +151,16 @@ impl Contact { || self.name_parts.is_some() || self.nick_names.is_some() || self.titles.is_some() + || self.roles.is_some() || self.organization_names.is_some() || self.postal_addresses.is_some() || self.emails.is_some() || self.phones.is_some() + || self.contact_uris.is_some() + || self.urls.is_some() } + /// Set the set of emails. pub fn set_emails(mut self, emails: &[impl ToString]) -> Self { let emails: Vec = emails .iter() @@ -73,6 +170,7 @@ impl Contact { self } + /// Add a voice phone to the set of phones. pub fn add_voice_phones(mut self, phones: &[impl ToString]) -> Self { let mut phones: Vec = phones .iter() @@ -91,6 +189,7 @@ impl Contact { self } + /// Add a facsimile phone to the set of phones. pub fn add_fax_phones(mut self, phones: &[impl ToString]) -> Self { let mut phones: Vec = phones .iter() @@ -109,6 +208,7 @@ impl Contact { self } + /// Set the set of postal addresses to only be the passed in postal address. pub fn set_postal_address(mut self, postal_address: PostalAddress) -> Self { self.postal_addresses = Some(vec![postal_address]); self @@ -163,7 +263,17 @@ pub struct PostalAddress { /// Work, home, etc.... Known as "type" in JCard. pub contexts: Option>, - /// An unstructured address. + /// An unstructured address. An unstructured postal address is + /// usually the complete postal address. That is, this string + /// would contain the street address, country, region, postal code, etc... + /// + /// Depending on how the postal address is given, it can either + /// be structured or unstructured. If it is given as unstructured, + /// then this value is populated. + /// + /// It is possible that a single postal address is given as both, + /// in which case this value is populated along with the other + /// values of the postal address. pub full_address: Option, /// Invidual street lines. diff --git a/icann-rdap-common/src/contact/to_vcard.rs b/icann-rdap-common/src/contact/to_vcard.rs index c2e97b1..e191ceb 100644 --- a/icann-rdap-common/src/contact/to_vcard.rs +++ b/icann-rdap-common/src/contact/to_vcard.rs @@ -1,3 +1,4 @@ +//! Convert a Contact to jCard/vCard. use std::str::FromStr; use serde_json::{json, Map, Value}; @@ -5,7 +6,22 @@ use serde_json::{json, Map, Value}; use super::Contact; impl Contact { - // Outputs the vcard array. + /// Output the Contact data as vCard in JSON values ([`Vec`]). + /// + /// ```rust + /// use icann_rdap_common::contact::Contact; + /// use serde::Serialize; + /// use serde_json::Value; + /// + /// let contact = Contact::builder() + /// .kind("individual") + /// .full_name("Bob Smurd") + /// .build(); + /// + /// let v = contact.to_vcard(); + /// let json = serde_json::to_string(&v); + /// ``` + pub fn to_vcard(&self) -> Vec { // start the vcard with the version. let mut vcard: Vec = vec![json!(["version", {}, "text", "4.0"])]; @@ -59,6 +75,12 @@ impl Contact { } } + if let Some(roles) = &self.roles { + for role in roles { + vcard.push(json!(["role", {}, "text", role])); + } + } + if let Some(nick_names) = &self.nick_names { for nick_name in nick_names { vcard.push(json!(["nickname", {}, "text", nick_name])); @@ -113,7 +135,7 @@ impl Contact { } let mut lines: Vec = Vec::new(); if let Some(street_parts) = &addr.street_parts { - lines.push(street_parts.get(0).cloned().unwrap_or("".to_string())); + lines.push(street_parts.first().cloned().unwrap_or("".to_string())); lines.push(street_parts.get(1).cloned().unwrap_or("".to_string())); lines.push(street_parts.get(2).cloned().unwrap_or("".to_string())); } else { @@ -149,6 +171,18 @@ impl Contact { } } + if let Some(contact_uris) = &self.contact_uris { + for uri in contact_uris { + vcard.push(json!(["contact-uri", {}, "uri", uri])); + } + } + + if let Some(urls) = &self.urls { + for url in urls { + vcard.push(json!(["url", {}, "uri", url])); + } + } + // return the vcard array vec![Value::String("vcard".to_string()), Value::from(vcard)] } @@ -174,7 +208,7 @@ fn vec_string_to_value(strings: &Option>) -> Value { Value::from(strings.clone()) } -fn vec_string_to_param(strings: &Vec) -> Value { +fn vec_string_to_param(strings: &[String]) -> Value { if strings.is_empty() { return Value::String("".to_string()); }; @@ -187,7 +221,7 @@ fn vec_string_to_param(strings: &Vec) -> Value { }; // else - Value::from(strings.clone()) + Value::from(strings) } #[cfg(test)] @@ -214,6 +248,8 @@ mod tests { ]) .organization_names(vec!["Example".to_string()]) .titles(vec!["Research Scientist".to_string()]) + .roles(vec!["Project Lead".to_string()]) + .contact_uris(vec!["https://example.com/contact-form".to_string()]) .postal_addresses(vec![PostalAddress::builder() .country_name("Canada") .postal_code("G1V 2M2") @@ -245,6 +281,7 @@ mod tests { .contexts(vec!["work".to_string()]) .email("joe.user@example.com") .build()]) + .urls(vec!["https://example.com/some-url".to_string()]) .build(); // WHEN @@ -257,8 +294,11 @@ mod tests { assert_eq!(contact.langs, actual.langs); assert_eq!(contact.organization_names, actual.organization_names); assert_eq!(contact.titles, actual.titles); + assert_eq!(contact.roles, actual.roles); assert_eq!(contact.postal_addresses, actual.postal_addresses); assert_eq!(contact.phones, actual.phones); assert_eq!(contact.emails, actual.emails); + assert_eq!(contact.contact_uris, actual.contact_uris); + assert_eq!(contact.urls, actual.urls); } } diff --git a/icann-rdap-common/src/dns_types.rs b/icann-rdap-common/src/dns_types.rs index 89df563..3c6e063 100644 --- a/icann-rdap-common/src/dns_types.rs +++ b/icann-rdap-common/src/dns_types.rs @@ -1,3 +1,5 @@ +//! DNS and DNSSEC types. + use thiserror::Error; #[derive(Debug, Error)] diff --git a/icann-rdap-common/src/iana.rs b/icann-rdap-common/src/iana.rs index 699b8ec..d873b9d 100644 --- a/icann-rdap-common/src/iana.rs +++ b/icann-rdap-common/src/iana.rs @@ -1,3 +1,5 @@ +//! The IANA RDAP Bootstrap Registries. + use ipnet::Ipv4Net; use ipnet::Ipv6Net; use prefix_trie::PrefixMap; diff --git a/icann-rdap-common/src/lib.rs b/icann-rdap-common/src/lib.rs index 5e6f4f5..3cbd987 100644 --- a/icann-rdap-common/src/lib.rs +++ b/icann-rdap-common/src/lib.rs @@ -12,8 +12,10 @@ pub mod response; #[cfg(debug_assertions)] use const_format::formatcp; +/// Version of this software. #[cfg(not(any(target_arch = "wasm32", debug_assertions)))] pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Version of this software. #[cfg(debug_assertions)] pub const VERSION: &str = formatcp!("{}_DEV_BUILD", env!("CARGO_PKG_VERSION")); diff --git a/icann-rdap-common/src/media_types.rs b/icann-rdap-common/src/media_types.rs index 2e29dc0..e3230ba 100644 --- a/icann-rdap-common/src/media_types.rs +++ b/icann-rdap-common/src/media_types.rs @@ -1,2 +1,4 @@ +//! RDAP media types (formerly known as mime types). + pub const JSON_MEDIA_TYPE: &str = "application/json"; pub const RDAP_MEDIA_TYPE: &str = "application/rdap+json"; diff --git a/icann-rdap-common/src/response/autnum.rs b/icann-rdap-common/src/response/autnum.rs index 4423503..49da2ad 100644 --- a/icann-rdap-common/src/response/autnum.rs +++ b/icann-rdap-common/src/response/autnum.rs @@ -6,7 +6,40 @@ use super::{ GetSelfLink, SelfLink, ToChild, }; -/// Represents an RDAP autnum object response. +/// Represents an RDAP [autnum](https://rdap.rcode3.com/protocol/object_classes.html#autnum) object response. +/// +/// Using the builder to construct this structure is recommended +/// as it will fill-in many of the mandatory fields. +/// The following is an example. +/// +/// ```rust +/// use icann_rdap_common::response::autnum::Autnum; +/// use icann_rdap_common::response::types::StatusValue; +/// +/// let autnum = Autnum::basic() +/// .autnum_range(700..710) // the range of autnums +/// .handle("AS700-1") +/// .status("active") +/// .build(); +/// let c = serde_json::to_string_pretty(&autnum).unwrap(); +/// eprintln!("{c}"); +/// ``` +/// This will produce the following. +/// +/// ```norust +/// { +/// "rdapConformance": [ +/// "rdap_level_0" +/// ], +/// "objectClassName": "autnum", +/// "handle": "AS700-1", +/// "status": [ +/// "active" +/// ], +/// "startAutnum": 700, +/// "endAutnum": 710 +/// } +/// ``` #[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] pub struct Autnum { #[serde(flatten)] @@ -68,7 +101,7 @@ impl Autnum { let events = (!events.is_empty()).then_some(events); let notices = (!notices.is_empty()).then_some(notices); Self { - common: Common::builder().and_notices(notices).build(), + common: Common::level0_with_options().and_notices(notices).build(), object_common: ObjectCommon::autnum() .and_handle(handle) .and_remarks(remarks) diff --git a/icann-rdap-common/src/response/domain.rs b/icann-rdap-common/src/response/domain.rs index 8b06a71..50eab97 100644 --- a/icann-rdap-common/src/response/domain.rs +++ b/icann-rdap-common/src/response/domain.rs @@ -103,7 +103,40 @@ pub struct SecureDns { pub key_data: Option>, } -/// Represents an RDAP domain response. +/// Represents an RDAP [domain](https://rdap.rcode3.com/protocol/object_classes.html#domain) response. +/// +/// Using the builder is recommended to construct this structure as it +/// will fill-in many of the mandatory fields. +/// The following is an example. +/// +/// ```rust +/// use icann_rdap_common::response::domain::Domain; +/// use icann_rdap_common::response::types::StatusValue; +/// +/// let domain = Domain::basic() +/// .ldh_name("foo.example.com") +/// .handle("foo_example_com-1") +/// .status("active") +/// .build(); +/// let c = serde_json::to_string_pretty(&domain).unwrap(); +/// eprintln!("{c}"); +/// ``` +/// +/// This will produce the following. +/// +/// ```norust +/// { +/// "rdapConformance": [ +/// "rdap_level_0" +/// ], +/// "objectClassName": "domain", +/// "handle": "foo_example_com-1", +/// "status": [ +/// "active" +/// ], +/// "ldhName": "foo.example.com" +/// } +/// ``` #[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] pub struct Domain { #[serde(flatten)] @@ -173,7 +206,7 @@ impl Domain { let events = (!events.is_empty()).then_some(events); let notices = (!notices.is_empty()).then_some(notices); Self { - common: Common::builder().and_notices(notices).build(), + common: Common::level0_with_options().and_notices(notices).build(), object_common: ObjectCommon::domain() .and_handle(handle) .and_remarks(remarks) @@ -576,11 +609,23 @@ mod tests { // GIVEN let mut domain = Domain::basic() .ldh_name("foo.example") - .link(Link::builder().href("http://bar.example").build()) + .link( + Link::builder() + .href("http://bar.example") + .value("http://bar.example") + .rel("unknown") + .build(), + ) .build(); // WHEN - domain = domain.set_self_link(Link::builder().href("http://foo.example").build()); + domain = domain.set_self_link( + Link::builder() + .href("http://foo.example") + .value("http://foo.example") + .rel("unknown") + .build(), + ); // THEN assert_eq!( diff --git a/icann-rdap-common/src/response/entity.rs b/icann-rdap-common/src/response/entity.rs index ad6934a..24f5a14 100644 --- a/icann-rdap-common/src/response/entity.rs +++ b/icann-rdap-common/src/response/entity.rs @@ -10,7 +10,73 @@ use super::{ GetSelfLink, SelfLink, ToChild, }; -/// Represents an RDAP entity response. +/// Represents an RDAP [entity](https://rdap.rcode3.com/protocol/object_classes.html#entity) response. +/// +/// Use of the builder is recommended when constructing this structure as it +/// will fill-in the mandatory fields. +/// The following is an example. +/// +/// ```rust +/// use icann_rdap_common::response::entity::Entity; +/// use icann_rdap_common::response::types::StatusValue; +/// use icann_rdap_common::contact::Contact; +/// +/// let contact = Contact::builder() +/// .kind("individual") +/// .full_name("Bob Smurd") +/// .build(); +/// +/// let entity = Entity::basic() +/// .handle("foo_example_com-1") +/// .status("active") +/// .role("registrant") +/// .contact(contact) +/// .build(); +/// let c = serde_json::to_string_pretty(&entity).unwrap(); +/// eprintln!("{c}"); +/// ``` +/// +/// This will produce the following. +/// +/// ```norust +/// { +/// "rdapConformance": [ +/// "rdap_level_0" +/// ], +/// "objectClassName": "entity", +/// "handle": "foo_example_com-1", +/// "status": [ +/// "active" +/// ], +/// "vcardArray": [ +/// "vcard", +/// [ +/// [ +/// "version", +/// {}, +/// "text", +/// "4.0" +/// ], +/// [ +/// "fn", +/// {}, +/// "text", +/// "Bob Smurd" +/// ], +/// [ +/// "kind", +/// {}, +/// "text", +/// "individual" +/// ] +/// ] +/// ], +/// "roles": [ +/// "registrant" +/// ] +/// } +/// ``` +/// #[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] pub struct Entity { #[serde(flatten)] @@ -84,7 +150,7 @@ impl Entity { let events = (!events.is_empty()).then_some(events); let notices = (!notices.is_empty()).then_some(notices); Self { - common: Common::builder().and_notices(notices).build(), + common: Common::level0_with_options().and_notices(notices).build(), object_common: ObjectCommon::entity() .handle(handle.into()) .and_remarks(remarks) @@ -105,9 +171,7 @@ impl Entity { } pub fn contact(&self) -> Option { - let Some(vcard) = &self.vcard_array else { - return None; - }; + let vcard = self.vcard_array.as_ref()?; Contact::from_vcard(vcard) } } diff --git a/icann-rdap-common/src/response/mod.rs b/icann-rdap-common/src/response/mod.rs index 54d5533..753e110 100644 --- a/icann-rdap-common/src/response/mod.rs +++ b/icann-rdap-common/src/response/mod.rs @@ -1,3 +1,4 @@ +//! RDAP structures for parsing and creating RDAP responses. use std::any::TypeId; use cidr_utils::cidr; diff --git a/icann-rdap-common/src/response/nameserver.rs b/icann-rdap-common/src/response/nameserver.rs index ffe0d33..3856ed7 100644 --- a/icann-rdap-common/src/response/nameserver.rs +++ b/icann-rdap-common/src/response/nameserver.rs @@ -38,7 +38,59 @@ impl IpAddresses { } } -/// Represents an RDAP nameserver response. +/// Represents an RDAP [nameserver](https://rdap.rcode3.com/protocol/object_classes.html#nameserver) response. +/// +/// Using the builder is recommended to construct this structure as it +/// will fill-in many of the mandatory fields. +/// The following is an example. +/// +/// ```rust +/// use icann_rdap_common::response::nameserver::Nameserver; +/// use icann_rdap_common::response::entity::Entity; +/// use icann_rdap_common::response::types::StatusValue; +/// +/// let ns = Nameserver::basic() +/// .ldh_name("ns1.example.com") +/// .handle("ns1_example_com-1") +/// .status("active") +/// .address("10.0.0.1") +/// .address("10.0.0.2") +/// .entity(Entity::basic().handle("FOO").build()) +/// .build().unwrap(); +/// let c = serde_json::to_string_pretty(&ns).unwrap(); +/// eprintln!("{c}"); +/// ``` +/// +/// This will produce the following. +/// +/// ```norust +/// { +/// "rdapConformance": [ +/// "rdap_level_0" +/// ], +/// "objectClassName": "nameserver", +/// "handle": "ns1_example_com-1", +/// "status": [ +/// "active" +/// ], +/// "entities": [ +/// { +/// "rdapConformance": [ +/// "rdap_level_0" +/// ], +/// "objectClassName": "entity", +/// "handle": "FOO" +/// } +/// ], +/// "ldhName": "ns1.example.com", +/// "ipAddresses": { +/// "v4": [ +/// "10.0.0.1", +/// "10.0.0.2" +/// ] +/// } +/// } +/// ``` #[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] pub struct Nameserver { #[serde(flatten)] @@ -103,7 +155,7 @@ impl Nameserver { let events = (!events.is_empty()).then_some(events); let notices = (!notices.is_empty()).then_some(notices); Ok(Self { - common: Common::builder().and_notices(notices).build(), + common: Common::level0_with_options().and_notices(notices).build(), object_common: ObjectCommon::nameserver() .and_handle(handle) .and_remarks(remarks) diff --git a/icann-rdap-common/src/response/network.rs b/icann-rdap-common/src/response/network.rs index cca8cc1..c44e758 100644 --- a/icann-rdap-common/src/response/network.rs +++ b/icann-rdap-common/src/response/network.rs @@ -25,31 +25,127 @@ impl std::fmt::Display for Cidr0Cidr { } } -#[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] +/// Represents a CIDR0 V4 CIDR. This structure allow both the prefix +/// and length to be optional to handle misbehaving servers, however +/// both are required according to the CIDR0 RDAP extension. To create +/// a valid stucture, use the builder. +/// +/// However, it is recommended to use the builder on `Network` which will +/// create the appropriate CIDR0 structure. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct V4Cidr { - pub v4prefix: String, - pub length: u8, + pub v4prefix: Option, + pub length: Option, +} + +#[buildstructor::buildstructor] +impl V4Cidr { + #[builder] + pub fn new(v4prefix: String, length: u8) -> Self { + V4Cidr { + v4prefix: Some(v4prefix), + length: Some(length), + } + } } impl std::fmt::Display for V4Cidr { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}/{}", self.v4prefix, self.length) + let length_s = if let Some(length) = self.length { + length.to_string() + } else { + "not_given".to_string() + }; + write!( + f, + "{}/{}", + self.v4prefix.as_ref().unwrap_or(&"not_given".to_string()), + length_s + ) } } -#[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] +/// Represents a CIDR0 V6 CIDR. This structure allow both the prefix +/// and length to be optional to handle misbehaving servers, however +/// both are required according to the CIDR0 RDAP extension. To create +/// a valid stucture, use the builder. +/// +/// However, it is recommended to use the builder on `Network` which will +/// create the appropriate CIDR0 structure. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct V6Cidr { - pub v6prefix: String, - pub length: u8, + pub v6prefix: Option, + pub length: Option, +} + +#[buildstructor::buildstructor] +impl V6Cidr { + #[builder] + pub fn new(v6prefix: String, length: u8) -> Self { + V6Cidr { + v6prefix: Some(v6prefix), + length: Some(length), + } + } } impl std::fmt::Display for V6Cidr { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}/{}", self.v6prefix, self.length) + let length_s = if let Some(length) = self.length { + length.to_string() + } else { + "not_given".to_string() + }; + write!( + f, + "{}/{}", + self.v6prefix.as_ref().unwrap_or(&"not_given".to_string()), + length_s + ) } } -/// Represents an RDAP network response. +/// Represents an RDAP [IP network](https://rdap.rcode3.com/protocol/object_classes.html#ip-network) response. +/// +/// Use of the builder is recommended to create this structure. +/// The builder will create the appropriate CIDR0 structures and +/// is easier than specifying start and end IP addresses. +/// +/// ```rust +/// use icann_rdap_common::response::network::Network; +/// use icann_rdap_common::response::types::StatusValue; +/// +/// let net = Network::basic() +/// .cidr("10.0.0.0/24") +/// .handle("NET-10-0-0-0") +/// .status("active") +/// .build().unwrap(); +/// ``` +/// +/// This will create the following RDAP structure. +/// +/// ```norust +/// { +/// "rdapConformance": [ +/// "cidr0", +/// "rdap_level_0" +/// ], +/// "objectClassName": "ip network", +/// "handle": "NET-10-0-0-0", +/// "status": [ +/// "active" +/// ], +/// "startAddress": "10.0.0.0", +/// "endAddress": "10.0.0.255", +/// "ipVersion": "v4", +/// "cidr0_cidrs": [ +/// { +/// "v4prefix": "10.0.0.0", +/// "length": 24 +/// } +/// ] +/// } +/// ``` #[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] pub struct Network { #[serde(flatten)] @@ -188,12 +284,12 @@ impl Network { country, cidr0_cidrs: match cidr { IpInet::V4(cidr) => Some(vec![Cidr0Cidr::V4Cidr(V4Cidr { - v4prefix: cidr.first_address().to_string(), - length: cidr.network_length(), + v4prefix: Some(cidr.first_address().to_string()), + length: Some(cidr.network_length()), })]), IpInet::V6(cidr) => Some(vec![Cidr0Cidr::V6Cidr(V6Cidr { - v6prefix: cidr.first_address().to_string(), - length: cidr.network_length(), + v6prefix: Some(cidr.first_address().to_string()), + length: Some(cidr.network_length()), })]), }, }) diff --git a/icann-rdap-common/src/response/types.rs b/icann-rdap-common/src/response/types.rs index 5cd64a4..3b30599 100644 --- a/icann-rdap-common/src/response/types.rs +++ b/icann-rdap-common/src/response/types.rs @@ -24,11 +24,44 @@ impl std::ops::Deref for Extension { /// The RDAP conformance array. pub type RdapConformance = Vec; +/// HrefLang, either a string or an array of strings. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(untagged)] +pub enum HrefLang { + Langs(Vec), + Lang(String), +} + /// An array of RDAP link structures. pub type Links = Vec; /// Represents and RDAP link structure. -#[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] +/// +/// This structure allows `value`, `rel`, and `href` to be +/// optional to be tolerant of misbehaving servers, +/// but those are fields required by RFC 9083. +/// +/// To create an RFC valid structure, use the builder +/// which will not allow omision of required fields. +/// +/// ```rust +/// use icann_rdap_common::response::types::Link; +/// +/// let link = Link::builder() +/// .value("https://example.com/domains?domain=foo.*") +/// .rel("related") +/// .href("https://example.com/domain/foo.example") +/// .hreflang("ch") +/// .title("Related Object") +/// .media("print") +/// .media_type("application/rdap+json") +/// .build(); +/// ``` +/// +/// Note also that this structure allows for `hreflang` to +/// be either a single string or an array of strings. However, +/// the builder will always construct an array of strings. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct Link { /// Represents the value part of a link in an RDAP response. /// According to RFC 9083, this field is required @@ -44,10 +77,14 @@ pub struct Link { #[serde(skip_serializing_if = "Option::is_none")] pub rel: Option, - pub href: String, + /// This is required by RDAP, both RFC 7043 and 9083, + /// but is optional because some servers do the wrong thing. + #[serde(skip_serializing_if = "Option::is_none")] + pub href: Option, + /// This can either be a string or an array of strings. #[serde(skip_serializing_if = "Option::is_none")] - pub hreflang: Option>, + pub hreflang: Option, #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, @@ -60,6 +97,7 @@ pub struct Link { pub media_type: Option, } +#[buildstructor::buildstructor] impl Link { pub fn is_relation(&self, rel: &str) -> bool { let Some(link_rel) = &self.rel else { @@ -67,6 +105,28 @@ impl Link { }; link_rel == rel } + + #[builder] + pub fn new( + value: String, + href: String, + rel: String, + hreflang: Option, + title: Option, + media: Option, + media_type: Option, + ) -> Self { + let hreflang = hreflang.map(HrefLang::Lang); + Link { + value: Some(value), + rel: Some(rel), + href: Some(href), + hreflang, + title, + media, + media_type, + } + } } /// An array of notices. @@ -100,25 +160,98 @@ impl std::ops::Deref for Remark { } /// Represents an RDAP Notice or Remark (they are the same thing in RDAP). -#[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] +/// +/// RFC 9083 requires that `description` be required, but some servers +/// do not follow this rule. Therefore, this structure allows `description` +/// to be optional. It is recommended to use builder to construct an RFC valie +/// structure. +/// +/// ```rust +/// use icann_rdap_common::response::types::NoticeOrRemark; +/// use icann_rdap_common::response::types::Link; +/// +/// let link = Link::builder() +/// .value("https://example.com/domains/foo.example") +/// .rel("about") +/// .href("https://example.com/tou.html") +/// .hreflang("en") +/// .title("ToU Link") +/// .media_type("text/html") +/// .build(); +/// +/// let nr = NoticeOrRemark::builder() +/// .title("Terms of Use") +/// .description_entry("Please read our terms of use.") +/// .links(vec![link]) +/// .build(); +/// ``` +/// +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct NoticeOrRemark { #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, - pub description: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub links: Option, } +#[buildstructor::buildstructor] +impl NoticeOrRemark { + #[builder] + pub fn new(title: Option, description: Vec, links: Option) -> Self { + NoticeOrRemark { + title, + description: Some(description), + links, + } + } +} + /// An array of events. pub type Events = Vec; /// Represents an RDAP event. -#[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] +/// +/// RFC 9083 requires `eventAction` (event_action) and `eventDate` (event_date), but +/// this structure allows those to be optional to be able to parse responses from +/// servers that do not strictly obey the RFC. +/// +/// Use of the builder to contruct an RFC valid structure is recommended. +/// +/// ```rust +/// use icann_rdap_common::response::types::Event; +/// use icann_rdap_common::response::types::Link; +/// +/// let link = Link::builder() +/// .value("https://example.com/domains/foo.example") +/// .rel("about") +/// .href("https://example.com/registration-duration.html") +/// .hreflang("en") +/// .title("Domain Validity Period") +/// .media_type("text/html") +/// .build(); +/// +/// let nr = Event::builder() +/// .event_action("expiration") +/// .event_date("1990-12-31T23:59:59Z") +/// .links(vec![link]) +/// .build(); +/// ``` +/// +/// NOTE: `event_date` is to be an RFC 3339 valid date and time. +/// The builder does not enforce RFC 3339 validity. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct Event { + /// This value is required by RFC 9083 (and 7483), + /// however some servers don't include it. Therefore + /// it is optional here to be compatible with these + /// types of non-compliant servers. #[serde(rename = "eventAction")] - pub event_action: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_action: Option, #[serde(rename = "eventActor")] #[serde(skip_serializing_if = "Option::is_none")] @@ -136,6 +269,24 @@ pub struct Event { pub links: Option, } +#[buildstructor::buildstructor] +impl Event { + #[builder] + pub fn new( + event_action: String, + event_date: String, + event_actor: Option, + links: Option, + ) -> Self { + Event { + event_action: Some(event_action), + event_actor, + event_date: Some(event_date), + links, + } + } +} + /// Represents an item in an RDAP status array. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct StatusValue(pub String); @@ -166,12 +317,42 @@ pub type Port43 = String; pub type PublicIds = Vec; /// An RDAP Public ID. -#[derive(Serialize, Deserialize, Builder, Clone, Debug, PartialEq, Eq)] +/// +/// RFC 9083 requires `type` (id_type) and `identifier`, but +/// this structure allows those to be optional to be able to parse responses from +/// servers that do not strictly obey the RFC. +/// +/// Use of the builder to contruct an RFC valid structure is recommended. +/// +/// ```rust +/// use icann_rdap_common::response::types::PublicId; +/// +/// let public_id = PublicId::builder() +/// .id_type("IANA Registrar ID") +/// .identifier("1990") +/// .build(); +/// ``` +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct PublicId { + /// This are manditory per RFC 9083. #[serde(rename = "type")] - pub id_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub id_type: Option, + + /// This are manditory per RFC 9083. + #[serde(skip_serializing_if = "Option::is_none")] + pub identifier: Option, +} - pub identifier: String, +#[buildstructor::buildstructor] +impl PublicId { + #[builder] + pub fn new(id_type: String, identifier: String) -> Self { + PublicId { + id_type: Some(id_type), + identifier: Some(identifier), + } + } } /// Holds those types that are common in all responses. @@ -453,8 +634,70 @@ mod tests { actual_2.value.as_ref().unwrap(), "https://2.example.com/context_uri" ); - assert_eq!(actual_1.href, "https://1.example.com/target_uri"); - assert_eq!(actual_2.href, "https://2.example.com/target_uri"); + assert_eq!( + actual_1.href.as_ref().unwrap(), + "https://1.example.com/target_uri" + ); + assert_eq!( + actual_2.href.as_ref().unwrap(), + "https://2.example.com/target_uri" + ); + assert_eq!(actual_1.title.as_ref().unwrap(), "title1"); + assert_eq!(actual_2.title.as_ref().unwrap(), "title2"); + assert_eq!(actual_1.media_type.as_ref().unwrap(), "application/json"); + assert_eq!(actual_2.media_type.as_ref().unwrap(), "application/json"); + } + + #[test] + fn GIVEN_an_array_of_links_with_one_lang_WHEN_deserialize_THEN_success() { + // GIVEN + let expected = r#" + [ + { + "value" : "https://1.example.com/context_uri", + "rel" : "self", + "href" : "https://1.example.com/target_uri", + "hreflang" : "en", + "title" : "title1", + "media" : "screen", + "type" : "application/json" + }, + { + "value" : "https://2.example.com/context_uri", + "rel" : "self", + "href" : "https://2.example.com/target_uri", + "hreflang" : "ch", + "title" : "title2", + "media" : "screen", + "type" : "application/json" + } + ] + "#; + + // WHEN + let links = serde_json::from_str::(expected); + + // THEN + let actual = links.unwrap(); + assert_eq!(actual.len(), 2); + let actual_1 = actual.first().unwrap(); + let actual_2 = actual.last().unwrap(); + assert_eq!( + actual_1.value.as_ref().unwrap(), + "https://1.example.com/context_uri" + ); + assert_eq!( + actual_2.value.as_ref().unwrap(), + "https://2.example.com/context_uri" + ); + assert_eq!( + actual_1.href.as_ref().unwrap(), + "https://1.example.com/target_uri" + ); + assert_eq!( + actual_2.href.as_ref().unwrap(), + "https://2.example.com/target_uri" + ); assert_eq!(actual_1.title.as_ref().unwrap(), "title1"); assert_eq!(actual_2.title.as_ref().unwrap(), "title2"); assert_eq!(actual_1.media_type.as_ref().unwrap(), "application/json"); @@ -490,7 +733,7 @@ mod tests { // THEN let actual = actual.unwrap(); actual.title.as_ref().unwrap(); - assert_eq!(actual.description.len(), 2); + assert_eq!(actual.description.expect("must have description").len(), 2); actual.links.unwrap(); } @@ -598,11 +841,21 @@ mod tests { fn GIVEN_no_self_links_WHEN_set_self_link_THEN_link_is_only_one() { // GIVEN let mut oc = ObjectCommon::domain() - .links(vec![Link::builder().href("http://bar.example").build()]) + .links(vec![Link::builder() + .href("http://bar.example") + .value("http://bar.example") + .rel("unknown") + .build()]) .build(); // WHEN - oc = oc.set_self_link(Link::builder().href("http://foo.example").build()); + oc = oc.set_self_link( + Link::builder() + .href("http://foo.example") + .value("http://foo.example") + .rel("unknown") + .build(), + ); // THEN assert_eq!( @@ -621,7 +874,13 @@ mod tests { let mut oc = ObjectCommon::domain().build(); // WHEN - oc = oc.set_self_link(Link::builder().href("http://foo.example").build()); + oc = oc.set_self_link( + Link::builder() + .href("http://foo.example") + .value("http://foo.example") + .rel("unknown") + .build(), + ); // THEN assert_eq!( @@ -640,12 +899,19 @@ mod tests { let mut oc = ObjectCommon::domain() .links(vec![Link::builder() .href("http://bar.example") + .value("http://bar.example") .rel("self") .build()]) .build(); // WHEN - oc = oc.set_self_link(Link::builder().href("http://foo.example").build()); + oc = oc.set_self_link( + Link::builder() + .href("http://foo.example") + .value("http://foo.example") + .rel("unknown") + .build(), + ); // THEN // new link is in @@ -654,7 +920,8 @@ mod tests { .as_ref() .expect("links are empty") .iter() - .filter(|link| link.is_relation("self") && link.href == "http://foo.example") + .filter(|link| link.is_relation("self") + && link.href.as_ref().unwrap() == "http://foo.example") .count(), 1 ); diff --git a/icann-rdap-srv/Cargo.toml b/icann-rdap-srv/Cargo.toml index a6520af..0245433 100644 --- a/icann-rdap-srv/Cargo.toml +++ b/icann-rdap-srv/Cargo.toml @@ -10,9 +10,10 @@ An RDAP Server. [dependencies] -icann-rdap-client = { version = "0.0.17", path = "../icann-rdap-client" } -icann-rdap-common = { version = "0.0.17", path = "../icann-rdap-common" } +icann-rdap-client = { version = "0.0.18", path = "../icann-rdap-client" } +icann-rdap-common = { version = "0.0.18", path = "../icann-rdap-common" } +ab-radix-trie.workspace = true async-trait.workspace = true axum.workspace = true axum-extra.workspace = true diff --git a/icann-rdap-srv/README.md b/icann-rdap-srv/README.md index 100db19..f6e80c3 100644 --- a/icann-rdap-srv/README.md +++ b/icann-rdap-srv/README.md @@ -11,6 +11,7 @@ by the Internet Corporation for Assigned Names and Numbers [(ICANN)](https://www RDAP is standard of the [IETF](https://ietf.org/), and extensions to RDAP are a current work activity of the IETF's [REGEXT working group](https://datatracker.ietf.org/wg/regext/documents/). More information on ICANN's role in RDAP can be found [here](https://www.icann.org/rdap). +General information on RDAP can be found [here](https://rdap.rcode3.com/). RDAP core support in this server is as follows: diff --git a/icann-rdap-srv/src/bin/rdap-srv-data.rs b/icann-rdap-srv/src/bin/rdap-srv-data.rs index ec6bd13..4de0780 100644 --- a/icann-rdap-srv/src/bin/rdap-srv-data.rs +++ b/icann-rdap-srv/src/bin/rdap-srv-data.rs @@ -48,6 +48,7 @@ use icann_rdap_srv::storage::data::NetworkOrError; use icann_rdap_srv::storage::data::Template; use icann_rdap_srv::storage::mem::config::MemConfig; use icann_rdap_srv::storage::mem::ops::Mem; +use icann_rdap_srv::storage::CommonConfig; use icann_rdap_srv::storage::StoreOps; use icann_rdap_srv::util::bin::check::check_rdap; use icann_rdap_srv::util::bin::check::to_check_classes; @@ -211,6 +212,7 @@ fn parse_notice_or_remark(arg: &str) -> Result links = Some(vec![Link::builder() .media_type(link_type.as_str().to_string()) .href(link_href.as_str().to_string()) + .value(link_href.as_str().to_string()) .rel(link_rel.as_str().to_string()) .build()]); } @@ -524,7 +526,11 @@ async fn main() -> Result<(), RdapServerError> { let data_dir = cli.data_dir.clone(); let config = ServiceConfig::non_server().data_dir(&data_dir).build()?; - let storage = Mem::new(MemConfig::builder().build()); + let storage = Mem::new( + MemConfig::builder() + .common_config(CommonConfig::default()) + .build(), + ); storage.init().await?; load_data(&config, &storage, false).await?; @@ -1193,7 +1199,10 @@ mod tests { let actual = parse_notice_or_remark(arg).expect("parsing notice"); // THEN - assert!(actual.description.contains(&arg.to_string())); + assert!(actual + .description + .expect("no description!") + .contains(&arg.to_string())); } #[test] @@ -1209,7 +1218,10 @@ mod tests { let actual = parse_notice_or_remark(&arg).expect("parsing notice"); // THEN - assert!(actual.description.contains(&description.to_string())); + assert!(actual + .description + .expect("no description!") + .contains(&description.to_string())); let Some(links) = actual.links else { panic!("no links in notice") }; @@ -1217,7 +1229,7 @@ mod tests { panic!("links are empty") }; assert_eq!(link.rel.as_ref().expect("no rel in link"), rel); - assert_eq!(link.href, href); + assert_eq!(link.href.as_ref().expect("link has no href"), href); assert_eq!( link.media_type.as_ref().expect("no media_type in link"), media_type diff --git a/icann-rdap-srv/src/bin/rdap-srv-test-data.rs b/icann-rdap-srv/src/bin/rdap-srv-test-data.rs index b7ae362..15bdd77 100644 --- a/icann-rdap-srv/src/bin/rdap-srv-test-data.rs +++ b/icann-rdap-srv/src/bin/rdap-srv-test-data.rs @@ -155,6 +155,7 @@ fn make_domain_template( Link::builder() .rel("self") .href(format!("https://{base_url}/domain/test-domain",)) + .value(format!("https://{base_url}/domain/test-domain",)) .media_type(RDAP_MEDIA_TYPE) .build(), ) @@ -202,6 +203,7 @@ fn make_autnum_template( Link::builder() .rel("self") .href(format!("https://{base_url}/autnum/test-autnum",)) + .value(format!("https://{base_url}/autnum/test-autnum",)) .media_type(RDAP_MEDIA_TYPE) .build(), ) @@ -318,6 +320,10 @@ fn make_test_entity(base_url: &str, child_of: Option<&str>) -> Entity { "https://{base_url}/entity/child_of_{}", child_of.unwrap_or("none") )) + .value(format!( + "https://{base_url}/entity/child_of_{}", + child_of.unwrap_or("none") + )) .media_type(RDAP_MEDIA_TYPE) .build(), ) @@ -360,6 +366,10 @@ fn make_test_nameserver( "https://{base_url}/nameserver/child_of_{}", child_of.unwrap_or("none") )) + .value(format!( + "https://{base_url}/nameserver/child_of_{}", + child_of.unwrap_or("none") + )) .media_type(RDAP_MEDIA_TYPE) .build(), ) @@ -386,6 +396,7 @@ fn make_test_network(base_url: &str) -> Result { Link::builder() .rel("self") .href(format!("https://{base_url}/ip/test_network",)) + .value(format!("https://{base_url}/ip/test_network",)) .media_type(RDAP_MEDIA_TYPE) .build(), ) diff --git a/icann-rdap-srv/src/bootstrap.rs b/icann-rdap-srv/src/bootstrap.rs index 6b0d194..29f3cf7 100644 --- a/icann-rdap-srv/src/bootstrap.rs +++ b/icann-rdap-srv/src/bootstrap.rs @@ -336,7 +336,7 @@ async fn fetch_iana_registry( } /// Prefer HTTPS urls. -fn get_preferred_url(urls: &Vec) -> Option { +fn get_preferred_url(urls: &[String]) -> Option { if urls.is_empty() { None } else { @@ -379,7 +379,7 @@ mod tests { storage::{ data::load_data, mem::{config::MemConfig, ops::Mem}, - StoreOps, + CommonConfig, StoreOps, }, }; @@ -618,7 +618,9 @@ mod tests { } async fn new_and_init_mem(data_dir: String) -> Mem { - let mem_config = MemConfig::builder().build(); + let mem_config = MemConfig::builder() + .common_config(CommonConfig::default()) + .build(); let mem = Mem::new(mem_config.clone()); mem.init().await.expect("initialzing memeory"); load_data( @@ -648,7 +650,10 @@ mod tests { let Some(first_link) = links.first() else { panic!("links are empty") }; - first_link.href.to_owned() + let Some(href) = &first_link.href else { + panic!("link has no href") + }; + href.clone() } #[tokio::test] diff --git a/icann-rdap-srv/src/config.rs b/icann-rdap-srv/src/config.rs index adfd83b..0d33ec8 100644 --- a/icann-rdap-srv/src/config.rs +++ b/icann-rdap-srv/src/config.rs @@ -1,11 +1,11 @@ use buildstructor::Builder; -use envmnt::get_or; +use envmnt::{get_or, get_parse_or}; use strum_macros::Display; use tracing::debug; use crate::{ error::RdapServerError, - storage::{mem::config::MemConfig, pg::config::PgConfig}, + storage::{mem::config::MemConfig, pg::config::PgConfig, CommonConfig}, }; pub const LOG: &str = "RDAP_SRV_LOG"; @@ -17,6 +17,7 @@ pub const DATA_DIR: &str = "RDAP_SRV_DATA_DIR"; pub const AUTO_RELOAD: &str = "RDAP_SRV_AUTO_RELOAD"; pub const BOOTSTRAP: &str = "RDAP_SRV_BOOTSTRAP"; pub const UPDATE_ON_BOOTSTRAP: &str = "RDAP_SRV_UPDATE_ON_BOOTSTRAP"; +pub const DOMAIN_SEARCH_BY_NAME_ENABLE: &str = "RDAP_SRV_DOMAIN_SEARCH_BY_NAME"; pub fn debug_config_vars() { let var_list = [ @@ -29,6 +30,7 @@ pub fn debug_config_vars() { AUTO_RELOAD, BOOTSTRAP, UPDATE_ON_BOOTSTRAP, + DOMAIN_SEARCH_BY_NAME_ENABLE, ]; envmnt::vars() .iter() @@ -65,12 +67,21 @@ pub enum StorageType { impl StorageType { pub fn new_from_env() -> Result { + let domain_search_by_name = get_parse_or(DOMAIN_SEARCH_BY_NAME_ENABLE, false)?; + let common_config = CommonConfig::builder() + .domain_search_by_name_enable(domain_search_by_name) + .build(); let storage = get_or(STORAGE, "memory"); let storage_type = if storage == "memory" { - StorageType::Memory(MemConfig::builder().build()) + StorageType::Memory(MemConfig::builder().common_config(common_config).build()) } else if storage == "postgres" { let db_url = get_or(DB_URL, "postgresql://127.0.0.1/rdap"); - StorageType::Postgres(PgConfig::builder().db_url(db_url).build()) + StorageType::Postgres( + PgConfig::builder() + .db_url(db_url) + .common_config(common_config) + .build(), + ) } else { return Err(RdapServerError::Config(format!( "storage type of '{storage}' is invalid" diff --git a/icann-rdap-srv/src/rdap/domains.rs b/icann-rdap-srv/src/rdap/domains.rs new file mode 100644 index 0000000..2018dec --- /dev/null +++ b/icann-rdap-srv/src/rdap/domains.rs @@ -0,0 +1,36 @@ +use axum::{ + extract::{Query, State}, + response::Response, +}; + +use serde::Deserialize; + +use crate::{error::RdapServerError, rdap::response::ResponseUtil, server::DynServiceState}; + +use super::response::NOT_IMPLEMENTED; + +#[derive(Debug, Deserialize)] +pub(crate) struct DomainsParams { + name: Option, + + #[serde(rename = "nsLdhName")] + _ns_ldh_name: Option, + + #[serde(rename = "nsIp")] + _ns_ip: Option, +} + +#[axum_macros::debug_handler] +#[tracing::instrument(level = "debug")] +pub(crate) async fn domains( + Query(params): Query, + state: State, +) -> Result { + if let Some(name) = params.name { + let storage = state.get_storage().await?; + let results = storage.search_domains_by_name(&name).await?; + Ok(results.response()) + } else { + Ok(NOT_IMPLEMENTED.response()) + } +} diff --git a/icann-rdap-srv/src/rdap/mod.rs b/icann-rdap-srv/src/rdap/mod.rs index 22d7600..87dd87c 100644 --- a/icann-rdap-srv/src/rdap/mod.rs +++ b/icann-rdap-srv/src/rdap/mod.rs @@ -2,6 +2,7 @@ use icann_rdap_common::response::{error::Error, RdapResponse}; pub mod autnum; pub mod domain; +pub mod domains; pub mod entity; pub mod ip; pub mod nameserver; @@ -69,7 +70,10 @@ fn bootstrap_redirect(error: Error, path: &str, id: &str) -> RdapResponse { let Some(link) = links.first() else { return RdapResponse::ErrorResponse(error); }; - let href = format!("{}{path}/{id}", link.href); + let Some(href) = &link.href else { + return RdapResponse::ErrorResponse(error); + }; + let href = format!("{}{path}/{id}", href); let redirect = Error::redirect().url(href).build(); RdapResponse::ErrorResponse(redirect) } diff --git a/icann-rdap-srv/src/rdap/response.rs b/icann-rdap-srv/src/rdap/response.rs index c16a754..1a01ced 100644 --- a/icann-rdap-srv/src/rdap/response.rs +++ b/icann-rdap-srv/src/rdap/response.rs @@ -50,19 +50,12 @@ impl ResponseUtil for RdapResponse { fn first_notice_link_href(&self) -> Option<&str> { if let RdapResponse::ErrorResponse(rdap_error) = self { - let Some(notices) = &rdap_error.common.notices else { - return None; - }; - let Some(first_notice) = notices.first() else { - return None; - }; - let Some(links) = &first_notice.0.links else { - return None; - }; - let Some(first_link) = links.first() else { - return None; - }; - Some(&first_link.href) + let notices = rdap_error.common.notices.as_ref()?; + let first_notice = notices.first()?; + let links = first_notice.0.links.as_ref()?; + let first_link = links.first()?; + let href = first_link.href.as_ref()?; + Some(href) } else { None } @@ -151,6 +144,8 @@ mod tests { NoticeOrRemark::builder() .links(vec![Link::builder() .href("https://other.example.com") + .value("https://other.example.com") + .rel("related") .build()]) .build(), )) diff --git a/icann-rdap-srv/src/rdap/router.rs b/icann-rdap-srv/src/rdap/router.rs index f55fe1b..235d1fa 100644 --- a/icann-rdap-srv/src/rdap/router.rs +++ b/icann-rdap-srv/src/rdap/router.rs @@ -3,6 +3,7 @@ use axum::{response::IntoResponse, routing::get, Router}; use super::{ autnum::autnum_by_num, domain::domain_by_name, + domains::domains, entity::entity_by_handle, ip::network_by_netid, nameserver::nameserver_by_name, @@ -17,7 +18,7 @@ pub(crate) fn rdap_router() -> Router { .route("/autnum/:asnumber", get(autnum_by_num)) .route("/nameserver/:name", get(nameserver_by_name)) .route("/entity/:handle", get(entity_by_handle)) - .route("/domains", get(not_implemented)) + .route("/domains", get(domains)) .route("/nameservers", get(not_implemented)) .route("/entities", get(not_implemented)) .route("/help", get(srvhelp)) diff --git a/icann-rdap-srv/src/storage/data.rs b/icann-rdap-srv/src/storage/data.rs index f16e50a..9fa1cf0 100644 --- a/icann-rdap-srv/src/storage/data.rs +++ b/icann-rdap-srv/src/storage/data.rs @@ -420,12 +420,16 @@ pub async fn trigger_update(data_dir: &str) -> Result<(), RdapServerError> { fn change_self_link(mut object: T, segment: &str, id: &str) -> T { if let Some(self_link) = object.get_self_link() { - if let Some(self_href) = self_link.href.rsplit_once(segment) { - let mut new_self_link = self_link.clone(); - new_self_link.href = format!("{}{segment}/{}", self_href.0, id); - object = object.set_self_link(new_self_link); + if let Some(self_href) = &self_link.href { + if let Some(self_href_split) = self_href.rsplit_once(segment) { + let mut new_self_link = self_link.clone(); + new_self_link.href = Some(format!("{}{segment}/{}", self_href_split.0, id)); + object = object.set_self_link(new_self_link); + } else { + warn!("Unable to rewrite self link for {segment} {}", id); + } } else { - warn!("Unable to rewrite self link for {segment} {}", id); + warn!("Unable to use self link because it has not href") } } else { warn!("No self link for {segment} {}", id); @@ -480,8 +484,8 @@ fn make_network_from_template( network.end_address = Some(v4.broadcast().to_string()); network.ip_version = Some("v4".to_string()); network.cidr0_cidrs = Some(vec![Cidr0Cidr::V4Cidr(V4Cidr { - v4prefix: v4.network().to_string(), - length: v4.prefix_len(), + v4prefix: Some(v4.network().to_string()), + length: Some(v4.prefix_len()), })]); } IpNet::V6(v6) => { @@ -489,8 +493,8 @@ fn make_network_from_template( network.end_address = Some(v6.broadcast().to_string()); network.ip_version = Some("v6".to_string()); network.cidr0_cidrs = Some(vec![Cidr0Cidr::V6Cidr(V6Cidr { - v6prefix: v6.network().to_string(), - length: v6.prefix_len(), + v6prefix: Some(v6.network().to_string()), + length: Some(v6.prefix_len()), })]); } }, @@ -505,8 +509,8 @@ fn make_network_from_template( Ipv4Subnets::new(start_address.parse()?, end_address.parse()?, 0) .map(|net| { Cidr0Cidr::V4Cidr(V4Cidr { - v4prefix: net.network().to_string(), - length: net.prefix_len(), + v4prefix: Some(net.network().to_string()), + length: Some(net.prefix_len()), }) }) .collect::>(), @@ -517,8 +521,8 @@ fn make_network_from_template( Ipv6Subnets::new(start_address.parse()?, end_address.parse()?, 0) .map(|net| { Cidr0Cidr::V6Cidr(V6Cidr { - v6prefix: net.network().to_string(), - length: net.prefix_len(), + v6prefix: Some(net.network().to_string()), + length: Some(net.prefix_len()), }) }) .collect::>(), @@ -535,10 +539,18 @@ fn make_network_from_template( .first() .map(|cidr| match cidr { Cidr0Cidr::V4Cidr(cidr) => { - format!("{}/{}", cidr.v4prefix, cidr.length) + format!( + "{}/{}", + cidr.v4prefix.as_ref().expect("no v4prefix"), + cidr.length.expect("no v4 length") + ) } Cidr0Cidr::V6Cidr(cidr) => { - format!("{}/{}", cidr.v6prefix, cidr.length) + format!( + "{}/{}", + cidr.v6prefix.as_ref().expect("no v6prefix"), + cidr.length.expect("no v6 length") + ) } }) .expect("cidrs on network are empty"); @@ -568,14 +580,14 @@ mod tests { // THEN assert_eq!( actual, - r#"{"domain":{"object":{"objectClassName":"domain","ldhName":"foo.example"}},"ids":[{"ldhName":"bar.example"}]}"# + r#"{"domain":{"object":{"rdapConformance":["rdap_level_0"],"objectClassName":"domain","ldhName":"foo.example"}},"ids":[{"ldhName":"bar.example"}]}"# ); } #[test] fn GIVEN_template_domain_text_WHEN_deserialize_THEN_success() { // GIVEN - let json_text = r#"{"domain":{"object":{"objectClassName":"domain","ldhName":"foo.example"}},"ids":[{"ldhName":"bar.example"}]}"#; + let json_text = r#"{"domain":{"object":{"rdapConformance":["rdap_level_0"],"objectClassName":"domain","ldhName":"foo.example"}},"ids":[{"ldhName":"bar.example"}]}"#; // WHEN let actual: Template = serde_json::from_str(json_text).expect("deserializing template"); @@ -771,6 +783,7 @@ mod tests { Link::builder() .rel("self") .href("http://reg.example/domain/foo.example") + .value("http://reg.example/domain/foo.example") .build(), ) .build(); @@ -785,7 +798,10 @@ mod tests { "bar.example" ); let self_link = actual.get_self_link().expect("self link messing"); - assert_eq!(self_link.href, "http://reg.example/domain/bar.example"); + assert_eq!( + self_link.href.as_ref().expect("link has no href"), + "http://reg.example/domain/bar.example" + ); } #[test] @@ -797,6 +813,7 @@ mod tests { Link::builder() .rel("self") .href("http://reg.example/entity/foo") + .value("http://reg.example/entity/foo") .build(), ) .build(); @@ -815,7 +832,10 @@ mod tests { "bar" ); let self_link = actual.get_self_link().expect("self link messing"); - assert_eq!(self_link.href, "http://reg.example/entity/bar"); + assert_eq!( + self_link.href.as_ref().expect("link has no href"), + "http://reg.example/entity/bar" + ); } #[test] @@ -827,6 +847,7 @@ mod tests { Link::builder() .rel("self") .href("http://reg.example/nameserver/ns.foo.example") + .value("http://reg.example/nameserver/ns.foo.example") .build(), ) .build() @@ -843,7 +864,7 @@ mod tests { ); let self_link = actual.get_self_link().expect("self link messing"); assert_eq!( - self_link.href, + self_link.href.as_ref().expect("link has no href"), "http://reg.example/nameserver/ns.bar.example" ); } @@ -857,6 +878,7 @@ mod tests { Link::builder() .rel("self") .href("http://reg.example/autnum/700") + .value("http://reg.example/autnum/700") .build(), ) .build(); @@ -875,7 +897,10 @@ mod tests { ); assert_eq!(*actual.end_autnum.as_ref().expect("no end on autnum"), 999); let self_link = actual.get_self_link().expect("self link messing"); - assert_eq!(self_link.href, "http://reg.example/autnum/900"); + assert_eq!( + self_link.href.as_ref().expect("link has href"), + "http://reg.example/autnum/900" + ); } #[test] @@ -887,6 +912,7 @@ mod tests { Link::builder() .rel("self") .href("http://reg.example/ip/10.0.0.0/24") + .value("http://reg.example/ip/10.0.0.0/24") .build(), ) .build() @@ -920,10 +946,13 @@ mod tests { let Cidr0Cidr::V4Cidr(v4cidr) = cidr0.first().expect("cidr0 is empty") else { panic!("no v4 cidr") }; - assert_eq!(v4cidr.v4prefix, "11.0.0.0"); - assert_eq!(v4cidr.length, 24); + assert_eq!(v4cidr.v4prefix, Some("11.0.0.0".to_string())); + assert_eq!(v4cidr.length, Some(24)); let self_link = actual.get_self_link().expect("self link messing"); - assert_eq!(self_link.href, "http://reg.example/ip/11.0.0.0/24"); + assert_eq!( + self_link.href.as_ref().expect("link has no href"), + "http://reg.example/ip/11.0.0.0/24" + ); } #[test] @@ -935,6 +964,7 @@ mod tests { Link::builder() .rel("self") .href("http://reg.example/ip/10.0.0.0/24") + .value("http://reg.example/ip/10.0.0.0/24") .build(), ) .build() @@ -967,9 +997,12 @@ mod tests { let Cidr0Cidr::V4Cidr(v4cidr) = cidr0.first().expect("cidr0 is empty") else { panic!("no v4 cidr") }; - assert_eq!(v4cidr.v4prefix, "11.0.0.0"); - assert_eq!(v4cidr.length, 24); + assert_eq!(v4cidr.v4prefix, Some("11.0.0.0".to_string())); + assert_eq!(v4cidr.length, Some(24)); let self_link = actual.get_self_link().expect("self link messing"); - assert_eq!(self_link.href, "http://reg.example/ip/11.0.0.0/24"); + assert_eq!( + self_link.href.as_ref().expect("link has no href"), + "http://reg.example/ip/11.0.0.0/24" + ); } } diff --git a/icann-rdap-srv/src/storage/mem/config.rs b/icann-rdap-srv/src/storage/mem/config.rs index eb6e7b4..0637db6 100644 --- a/icann-rdap-srv/src/storage/mem/config.rs +++ b/icann-rdap-srv/src/storage/mem/config.rs @@ -1,4 +1,8 @@ use buildstructor::Builder; +use crate::storage::CommonConfig; + #[derive(Debug, Builder, Clone)] -pub struct MemConfig {} +pub struct MemConfig { + pub common_config: CommonConfig, +} diff --git a/icann-rdap-srv/src/storage/mem/label_search.rs b/icann-rdap-srv/src/storage/mem/label_search.rs new file mode 100644 index 0000000..3004aea --- /dev/null +++ b/icann-rdap-srv/src/storage/mem/label_search.rs @@ -0,0 +1,366 @@ +use std::collections::HashMap; + +use ab_radix_trie::Trie; +use buildstructor::Builder; + +use crate::error::RdapServerError; + +/// A structure for searching DNS labels as specified in RFC 9082. +/// For RDAP, type T is likely RdapResponse or Arc. +#[derive(Builder)] +pub struct SearchLabels { + label_suffixes: HashMap>, +} + +impl SearchLabels { + /// Insert a value based on a domain name. + pub(crate) fn insert(&mut self, text: &str, value: T) { + // char_indices gets the UTF8 indices as well as the character + for (i, char) in text.char_indices() { + if char == '.' && i != 0 { + let prefix = &text[..i]; + // find the next UTF8 character index + let mut next_i = i + 1; + while !text.is_char_boundary(next_i) { + next_i += 1; + } + let suffix = &text[next_i..]; + self.label_suffixes + .entry(suffix.to_owned()) + .or_insert(Trie::new()) + .insert(prefix, Some(value.clone())); + } + } + // the root + self.label_suffixes + .entry(String::default()) + .or_insert(Trie::new()) + .insert(text, Some(value.clone())); + } + + /// Search values based on a label search + pub(crate) fn search(&self, search: &str) -> Result, RdapServerError> { + // search string is invalid if it doesn't have only one asterisk ('*') + if search.chars().filter(|c| *c == '*').count() != 1 { + return Err(RdapServerError::InvalidArg( + "Search string must contain one and only one asterisk ('*')".to_string(), + )); + } + // asterisk must not be followed by a character other than dot ('.') + let star = search + .find('*') + .expect("internal error. previous check should have caught this"); + if star != search.chars().count() - 1 + && search + .chars() + .nth(star + 1) + .expect("should have been short circuited") + != '.' + { + return Err(RdapServerError::InvalidArg( + "Search string asterisk ('*') must terminate domain label".to_string(), + )); + } + + let parts = search + .split_once('*') + .expect("internal error. previous check should insure there is an asterisk"); + + // this is a limitation of the trie in that it requires a prefix + if parts.0.is_empty() { + return Err(RdapServerError::InvalidArg( + "Search string must have a prefix".to_string(), + )); + } + + if let Some(trie) = self.label_suffixes.get(parts.1.trim_start_matches('.')) { + if let Some(entries) = trie.get_suffixes_values(parts.0) { + if !entries.is_empty() { + let values = entries + .iter() + .filter_map(|e| e.val.clone()) + .collect::>(); + return Ok(values); + } + } + } + + Ok(vec![]) + } +} + +#[cfg(test)] +#[allow(non_snake_case)] +mod tests { + + use ab_radix_trie::{Entry, Trie}; + + use super::SearchLabels; + + #[test] + fn GIVEN_domain_names_WHEN_inserting_THEN_search_labels_is_correct() { + // GIVEN + let mut search = SearchLabels::builder().build(); + + // WHEN + search.insert("foo.example.com", "foo.example.com".to_owned()); + search.insert("bar.example.com", "bar.example.com".to_owned()); + search.insert("foo.example.net", "foo.example.net".to_owned()); + search.insert("bar.example.net", "bar.example.net".to_owned()); + + // THEN + dbg!(&search.label_suffixes); + assert_eq!(search.label_suffixes.len(), 5); + // root + let root = search.label_suffixes.get("").expect("no root"); + assert_trie( + root, + "foo.example.", + &["foo.example.com", "foo.example.net"], + &["bar.example.com", "bar.example.net"], + ); + assert_trie( + root, + "bar.example.", + &["bar.example.com", "bar.example.net"], + &["foo.example.com", "foo.example.net"], + ); + // com + let com = search.label_suffixes.get("com").expect("no trie"); + assert_trie( + com, + "foo.example", + &["foo.example.com"], + &["bar.example.com", "bar.example.net", "foo.example.net"], + ); + assert_trie( + com, + "bar.example", + &["bar.example.com"], + &["foo.example.com", "foo.example.net", "bar.example.net"], + ); + // net + let net = search.label_suffixes.get("net").expect("no trie"); + assert_trie( + net, + "foo.example", + &["foo.example.net"], + &["bar.example.net", "bar.example.com", "foo.example.com"], + ); + assert_trie( + net, + "bar.example", + &["bar.example.net"], + &["foo.example.com", "foo.example.net", "bar.example.com"], + ); + // example.com + let example_com = search.label_suffixes.get("example.com").expect("no trie"); + assert_trie( + example_com, + "foo", + &["foo.example.com"], + &["bar.example.com", "bar.example.net", "foo.example.net"], + ); + assert_trie( + example_com, + "bar", + &["bar.example.com"], + &["foo.example.com", "foo.example.net", "bar.example.net"], + ); + // example.net + let example_net = search.label_suffixes.get("example.net").expect("no trie"); + assert_trie( + example_net, + "foo", + &["foo.example.net"], + &["bar.example.net", "bar.example.com", "foo.example.com"], + ); + assert_trie( + example_net, + "bar", + &["bar.example.net"], + &["foo.example.com", "foo.example.net", "bar.example.com"], + ); + } + + fn assert_trie(trie: &Trie, suffix: &str, must_have: &[&str], must_not_have: &[&str]) { + let entries = trie + .get_suffixes_values(suffix) + .expect("no values in entries"); + for s in must_have { + assert!( + trie_contains(&entries, s), + "suffix = {suffix} did not find {s}" + ); + } + for s in must_not_have { + assert!(!trie_contains(&entries, s), "suffix = {suffix} found {s}"); + } + } + + fn trie_contains(entries: &[Entry<'_, String>], value: &str) -> bool { + entries + .iter() + .any(|e| e.val.as_ref().expect("no entry value") == value) + } + + #[test] + fn GIVEN_search_string_with_two_asterisks_WHEN_search_THEN_error() { + // GIVEN + let labels: SearchLabels = SearchLabels::builder().build(); + let search = "foo.*.*"; + + // WHEN + let actual = labels.search(search); + + // THEN + assert!(actual.is_err()); + } + + #[test] + fn GIVEN_search_string_with_asterisk_suffix_WHEN_search_THEN_error() { + // GIVEN + let labels: SearchLabels = SearchLabels::builder().build(); + let search = "foo.*example.net"; + + // WHEN + let actual = labels.search(search); + + // THEN + assert!(actual.is_err()); + } + + #[test] + fn GIVEN_search_string_with_no_asterisk_WHEN_search_THEN_error() { + // GIVEN + let labels: SearchLabels = SearchLabels::builder().build(); + let search = "foo.example.net"; + + // WHEN + let actual = labels.search(search); + + // THEN + assert!(actual.is_err()); + } + + #[test] + fn GIVEN_empty_search_string_WHEN_search_THEN_error() { + // GIVEN + let labels: SearchLabels = SearchLabels::builder().build(); + let search = ""; + + // WHEN + let actual = labels.search(search); + + // THEN + assert!(actual.is_err()); + } + + #[test] + fn GIVEN_root_search_WHEN_search_THEN_correct_values_found() { + // GIVEN + let mut labels = SearchLabels::builder().build(); + labels.insert("foo.example.com", "foo.example.com".to_owned()); + labels.insert("bar.example.com", "bar.example.com".to_owned()); + labels.insert("foo.example.net", "foo.example.net".to_owned()); + labels.insert("bar.example.net", "bar.example.net".to_owned()); + + // WHEN + let actual = labels.search("foo.example.*").expect("search is invalid"); + + // THEN + dbg!(&actual); + assert_eq!(actual.len(), 2); + assert!(actual.contains(&"foo.example.com".to_string())); + assert!(actual.contains(&"foo.example.net".to_string())); + } + + #[test] + fn GIVEN_root_search_WHEN_search_with_prefix_THEN_correct_values_found() { + // GIVEN + let mut labels = SearchLabels::builder().build(); + labels.insert("foo.example.com", "foo.example.com".to_owned()); + labels.insert("bar.example.com", "bar.example.com".to_owned()); + labels.insert("foo.example.net", "foo.example.net".to_owned()); + labels.insert("bar.example.net", "bar.example.net".to_owned()); + + // WHEN + let actual = labels.search("foo.example.n*").expect("search is invalid"); + + // THEN + dbg!(&actual); + assert_eq!(actual.len(), 1); + assert!(actual.contains(&"foo.example.net".to_string())); + } + + #[test] + fn GIVEN_labels_WHEN_sld_search_with_prefix_THEN_correct_values_found() { + // GIVEN + let mut labels = SearchLabels::builder().build(); + labels.insert("foo.example.com", "foo.example.com".to_owned()); + labels.insert("bar.example.com", "bar.example.com".to_owned()); + labels.insert("foo.example.net", "foo.example.net".to_owned()); + labels.insert("bar.example.net", "bar.example.net".to_owned()); + + // WHEN + let actual = labels.search("foo.ex*.com").expect("search is invalid"); + + // THEN + dbg!(&actual); + assert_eq!(actual.len(), 1); + assert!(actual.contains(&"foo.example.com".to_string())); + } + + #[test] + fn GIVEN_labels_WHEN_3ld_search_with_prefix_THEN_correct_values_found() { + // GIVEN + let mut labels = SearchLabels::builder().build(); + labels.insert("foo.example.com", "foo.example.com".to_owned()); + labels.insert("bar.example.com", "bar.example.com".to_owned()); + labels.insert("foo.example.net", "foo.example.net".to_owned()); + labels.insert("bar.example.net", "bar.example.net".to_owned()); + + // WHEN + let actual = labels.search("fo*.example.com").expect("search is invalid"); + + // THEN + dbg!(&actual); + assert_eq!(actual.len(), 1); + assert!(actual.contains(&"foo.example.com".to_string())); + } + + #[test] + fn GIVEN_labels_WHEN_sld_search_THEN_correct_values_found() { + // GIVEN + let mut labels = SearchLabels::builder().build(); + labels.insert("foo.example.com", "foo.example.com".to_owned()); + labels.insert("bar.example.com", "bar.example.com".to_owned()); + labels.insert("foo.example.net", "foo.example.net".to_owned()); + labels.insert("bar.example.net", "bar.example.net".to_owned()); + + // WHEN + let actual = labels.search("foo.*.com").expect("search is invalid"); + + // THEN + dbg!(&actual); + assert_eq!(actual.len(), 1); + assert!(actual.contains(&"foo.example.com".to_string())); + } + + #[test] + fn GIVEN_labels_WHEN_3ld_search_THEN_error() { + // GIVEN + let mut labels = SearchLabels::builder().build(); + labels.insert("foo.example.com", "foo.example.com".to_owned()); + labels.insert("bar.example.com", "bar.example.com".to_owned()); + labels.insert("foo.example.net", "foo.example.net".to_owned()); + labels.insert("bar.example.net", "bar.example.net".to_owned()); + + // WHEN + let actual = labels.search("*.example.com"); + + // THEN + dbg!(&actual); + assert!(actual.is_err()); + } +} diff --git a/icann-rdap-srv/src/storage/mem/mod.rs b/icann-rdap-srv/src/storage/mem/mod.rs index 04990e2..5a36145 100644 --- a/icann-rdap-srv/src/storage/mem/mod.rs +++ b/icann-rdap-srv/src/storage/mem/mod.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] // TODO remove pub mod config; +mod label_search; pub mod ops; pub mod tx; diff --git a/icann-rdap-srv/src/storage/mem/ops.rs b/icann-rdap-srv/src/storage/mem/ops.rs index 412e93c..68963ab 100644 --- a/icann-rdap-srv/src/storage/mem/ops.rs +++ b/icann-rdap-srv/src/storage/mem/ops.rs @@ -2,18 +2,20 @@ use std::{collections::HashMap, net::IpAddr, str::FromStr, sync::Arc}; use async_trait::async_trait; use btree_range_map::RangeMap; -use icann_rdap_common::response::RdapResponse; +use icann_rdap_common::response::{ + domain::Domain, search::DomainSearchResults, types::Common, RdapResponse, +}; use ipnet::{IpNet, Ipv4Net, Ipv6Net}; use prefix_trie::PrefixMap; use tokio::sync::RwLock; use crate::{ error::RdapServerError, - rdap::response::NOT_FOUND, - storage::{StoreOps, TxHandle}, + rdap::response::{NOT_FOUND, NOT_IMPLEMENTED}, + storage::{CommonConfig, StoreOps, TxHandle}, }; -use super::{config::MemConfig, tx::MemTx}; +use super::{config::MemConfig, label_search::SearchLabels, tx::MemTx}; #[derive(Clone)] pub struct Mem { @@ -21,6 +23,7 @@ pub struct Mem { pub(crate) ip4: Arc>>>, pub(crate) ip6: Arc>>>, pub(crate) domains: Arc>>>, + pub(crate) domains_by_name: Arc>>>, pub(crate) idns: Arc>>>, pub(crate) nameservers: Arc>>>, pub(crate) entities: Arc>>>, @@ -35,6 +38,7 @@ impl Mem { ip4: Arc::new(RwLock::new(PrefixMap::new())), ip6: Arc::new(RwLock::new(PrefixMap::new())), domains: Arc::new(RwLock::new(HashMap::new())), + domains_by_name: Arc::new(RwLock::new(SearchLabels::builder().build())), idns: Arc::new(RwLock::new(HashMap::new())), nameservers: Arc::new(RwLock::new(HashMap::new())), entities: Arc::new(RwLock::new(HashMap::new())), @@ -46,7 +50,11 @@ impl Mem { impl Default for Mem { fn default() -> Self { - Mem::new(MemConfig::builder().build()) + Mem::new( + MemConfig::builder() + .common_config(CommonConfig::default()) + .build(), + ) } } @@ -164,4 +172,29 @@ impl StoreOps for Mem { None => Ok(NOT_FOUND.clone()), } } + + async fn search_domains_by_name(&self, name: &str) -> Result { + if !self.config.common_config.domain_search_by_name_enable { + return Ok(NOT_IMPLEMENTED.clone()); + } + //else + let domains_by_name = self.domains_by_name.read().await; + let results = domains_by_name + .search(name) + .unwrap_or_default() + .into_iter() + .map(Arc::::unwrap_or_clone) + .filter_map(|d| match d { + RdapResponse::Domain(d) => Some(d), + _ => None, + }) + .collect::>(); + let response = RdapResponse::DomainSearchResults( + DomainSearchResults::builder() + .common(Common::new_level0(vec![], vec![])) + .results(results) + .build(), + ); + Ok(response) + } } diff --git a/icann-rdap-srv/src/storage/mem/tx.rs b/icann-rdap-srv/src/storage/mem/tx.rs index 405ee87..221b89f 100644 --- a/icann-rdap-srv/src/storage/mem/tx.rs +++ b/icann-rdap-srv/src/storage/mem/tx.rs @@ -17,7 +17,7 @@ use crate::{ }, }; -use super::ops::Mem; +use super::{label_search::SearchLabels, ops::Mem}; pub struct MemTx { mem: Mem, @@ -25,6 +25,7 @@ pub struct MemTx { ip4: PrefixMap>, ip6: PrefixMap>, domains: HashMap>, + domains_by_name: SearchLabels>, idns: HashMap>, nameservers: HashMap>, entities: HashMap>, @@ -33,12 +34,23 @@ pub struct MemTx { impl MemTx { pub async fn new(mem: &Mem) -> Self { + let domains = Arc::clone(&mem.domains).read_owned().await.clone(); + let mut domains_by_name = SearchLabels::builder().build(); + + // only do load up domain search labels if search by domain names is supported + if mem.config.common_config.domain_search_by_name_enable { + for (name, value) in domains.iter() { + domains_by_name.insert(name, value.clone()); + } + } + Self { mem: mem.clone(), autnums: Arc::clone(&mem.autnums).read_owned().await.clone(), ip4: Arc::clone(&mem.ip4).read_owned().await.clone(), ip6: Arc::clone(&mem.ip6).read_owned().await.clone(), - domains: Arc::clone(&mem.domains).read_owned().await.clone(), + domains, + domains_by_name, idns: Arc::clone(&mem.idns).read_owned().await.clone(), nameservers: Arc::clone(&mem.nameservers).read_owned().await.clone(), entities: Arc::clone(&mem.entities).read_owned().await.clone(), @@ -53,6 +65,7 @@ impl MemTx { ip4: PrefixMap::new(), ip6: PrefixMap::new(), domains: HashMap::new(), + domains_by_name: SearchLabels::builder().build(), idns: HashMap::new(), nameservers: HashMap::new(), entities: HashMap::new(), @@ -101,9 +114,14 @@ impl TxHandle for MemTx { // add the domain by unicodeName if let Some(unicode_name) = domain.unicode_name.as_ref() { - self.idns.insert(unicode_name.to_owned(), domain_response); + self.idns + .insert(unicode_name.to_owned(), domain_response.clone()); }; + if self.mem.config.common_config.domain_search_by_name_enable { + self.domains_by_name.insert(ldh_name, domain_response); + } + Ok(()) } @@ -282,6 +300,10 @@ impl TxHandle for MemTx { let mut domains_g = self.mem.domains.write().await; std::mem::swap(&mut self.domains, &mut domains_g); + //domains by name + let mut domains_by_name_g = self.mem.domains_by_name.write().await; + std::mem::swap(&mut self.domains_by_name, &mut domains_by_name_g); + //idns let mut idns_g = self.mem.idns.write().await; std::mem::swap(&mut self.idns, &mut idns_g); diff --git a/icann-rdap-srv/src/storage/mod.rs b/icann-rdap-srv/src/storage/mod.rs index ac164e9..f535a28 100644 --- a/icann-rdap-srv/src/storage/mod.rs +++ b/icann-rdap-srv/src/storage/mod.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use buildstructor::Builder; use icann_rdap_common::response::{ autnum::Autnum, domain::Domain, entity::Entity, help::Help, nameserver::Nameserver, network::Network, RdapResponse, @@ -52,6 +53,9 @@ pub trait StoreOps: Send + Sync { /// Get server help. async fn get_srv_help(&self, host: Option<&str>) -> Result; + + /// Search for domains by name. + async fn search_domains_by_name(&self, name: &str) -> Result; } /// Represents a handle to a transaction. @@ -121,3 +125,17 @@ pub trait TxHandle: Send { /// Rollback the transaction. async fn rollback(self: Box) -> Result<(), RdapServerError>; } + +/// Common configuration for storage back ends. +#[derive(Debug, Clone, Copy, Builder)] +pub struct CommonConfig { + pub domain_search_by_name_enable: bool, +} + +impl Default for CommonConfig { + fn default() -> Self { + CommonConfig { + domain_search_by_name_enable: true, + } + } +} diff --git a/icann-rdap-srv/src/storage/pg/config.rs b/icann-rdap-srv/src/storage/pg/config.rs index bb76a90..94051df 100644 --- a/icann-rdap-srv/src/storage/pg/config.rs +++ b/icann-rdap-srv/src/storage/pg/config.rs @@ -1,6 +1,9 @@ use buildstructor::Builder; +use crate::storage::CommonConfig; + #[derive(Debug, Builder, Clone)] pub struct PgConfig { pub db_url: String, + pub common_config: CommonConfig, } diff --git a/icann-rdap-srv/src/storage/pg/ops.rs b/icann-rdap-srv/src/storage/pg/ops.rs index cad3148..d3b5207 100644 --- a/icann-rdap-srv/src/storage/pg/ops.rs +++ b/icann-rdap-srv/src/storage/pg/ops.rs @@ -70,4 +70,7 @@ impl StoreOps for Pg { async fn get_srv_help(&self, _host: Option<&str>) -> Result { todo!() } + async fn search_domains_by_name(&self, _name: &str) -> Result { + todo!() + } } diff --git a/icann-rdap-srv/tests/integration/srv/domain.rs b/icann-rdap-srv/tests/integration/srv/domain.rs index 7a17b8d..b3d2413 100644 --- a/icann-rdap-srv/tests/integration/srv/domain.rs +++ b/icann-rdap-srv/tests/integration/srv/domain.rs @@ -1,11 +1,14 @@ #![allow(non_snake_case)] -use icann_rdap_client::query::{qtype::QueryType, request::rdap_request}; +use icann_rdap_client::{ + query::{qtype::QueryType, request::rdap_request}, + RdapClientError, +}; use icann_rdap_common::{ client::{create_client, ClientConfig}, response::domain::Domain, }; -use icann_rdap_srv::storage::StoreOps; +use icann_rdap_srv::storage::{CommonConfig, StoreOps}; use crate::test_jig::SrvTestJig; @@ -63,3 +66,60 @@ async fn GIVEN_server_with_idn_WHEN_query_domain_THEN_status_code_200() { // THEN assert_eq!(response.http_data.status_code, 200); } + +#[tokio::test] +async fn GIVEN_server_with_domain_and_search_disabled_WHEN_query_domain_THEN_status_code_501() { + // GIVEN + let common_config = CommonConfig::builder() + .domain_search_by_name_enable(false) + .build(); + let test_srv = SrvTestJig::new_common_config(common_config).await; + let mut tx = test_srv.mem.new_tx().await.expect("new transaction"); + tx.add_domain(&Domain::basic().ldh_name("foo.example").build()) + .await + .expect("add domain in tx"); + tx.commit().await.expect("tx commit"); + + // WHEN + let client_config = ClientConfig::builder() + .https_only(false) + .follow_redirects(false) + .build(); + let client = create_client(&client_config).expect("creating client"); + let query = QueryType::DomainNameSearch("foo.*".to_string()); + let response = rdap_request(&test_srv.rdap_base, &query, &client).await; + + // THEN + let RdapClientError::Client(error) = response.expect_err("not an error response") else { + panic!("the error was not an HTTP error") + }; + assert_eq!(error.status().expect("no status code"), 501); +} + +#[tokio::test] +async fn GIVEN_server_with_domain_and_search_enabled_WHEN_query_domain_THEN_status_code_200() { + // GIVEN + let common_config = CommonConfig::builder() + .domain_search_by_name_enable(true) + .build(); + let test_srv = SrvTestJig::new_common_config(common_config).await; + let mut tx = test_srv.mem.new_tx().await.expect("new transaction"); + tx.add_domain(&Domain::basic().ldh_name("foo.example").build()) + .await + .expect("add domain in tx"); + tx.commit().await.expect("tx commit"); + + // WHEN + let client_config = ClientConfig::builder() + .https_only(false) + .follow_redirects(false) + .build(); + let client = create_client(&client_config).expect("creating client"); + let query = QueryType::DomainNameSearch("foo.*".to_string()); + let response = rdap_request(&test_srv.rdap_base, &query, &client) + .await + .expect("quering server"); + + // THEN + assert_eq!(response.http_data.status_code, 200); +} diff --git a/icann-rdap-srv/tests/integration/srv/redirect.rs b/icann-rdap-srv/tests/integration/srv/redirect.rs index d885f16..5548de6 100644 --- a/icann-rdap-srv/tests/integration/srv/redirect.rs +++ b/icann-rdap-srv/tests/integration/srv/redirect.rs @@ -31,6 +31,8 @@ async fn GIVEN_domain_error_with_first_link_href_WHEN_query_THEN_status_code_is_ NoticeOrRemark::builder() .links(vec![Link::builder() .href("https://other.example.com") + .value("https://other.example.com") + .rel("about") .build()]) .build(), )) @@ -79,6 +81,8 @@ async fn GIVEN_nameserver_error_with_first_link_href_WHEN_query_THEN_status_code NoticeOrRemark::builder() .links(vec![Link::builder() .href("https://other.example.com") + .value("https://other.example.com") + .rel("about") .build()]) .build(), )) @@ -126,6 +130,8 @@ async fn GIVEN_entity_error_with_first_link_href_WHEN_query_THEN_status_code_is_ NoticeOrRemark::builder() .links(vec![Link::builder() .href("https://other.example.com") + .value("https://other.example.com") + .rel("about") .build()]) .build(), )) @@ -174,6 +180,8 @@ async fn GIVEN_autnum_error_with_first_link_href_WHEN_query_THEN_status_code_is_ NoticeOrRemark::builder() .links(vec![Link::builder() .href("https://other.example.com") + .value("https://other.example.com") + .rel("about") .build()]) .build(), )) @@ -221,6 +229,8 @@ async fn GIVEN_network_cidr_error_with_first_link_href_WHEN_query_THEN_status_co NoticeOrRemark::builder() .links(vec![Link::builder() .href("https://other.example.com") + .value("https://other.example.com") + .rel("about") .build()]) .build(), )) @@ -271,6 +281,8 @@ async fn GIVEN_network_addrs_error_with_first_link_href_WHEN_query_THEN_status_c NoticeOrRemark::builder() .links(vec![Link::builder() .href("https://other.example.com") + .value("https://other.example.com") + .rel("about") .build()]) .build(), )) diff --git a/icann-rdap-srv/tests/integration/storage/data.rs b/icann-rdap-srv/tests/integration/storage/data.rs index b2cde80..0853ad3 100644 --- a/icann-rdap-srv/tests/integration/storage/data.rs +++ b/icann-rdap-srv/tests/integration/storage/data.rs @@ -19,13 +19,15 @@ use icann_rdap_srv::{ NetworkId, NetworkIdType, NetworkOrError::NetworkObject, Template, }, mem::{config::MemConfig, ops::Mem}, - StoreOps, + CommonConfig, StoreOps, }, }; use test_dir::{DirBuilder, TestDir}; async fn new_and_init_mem(data_dir: String) -> Mem { - let mem_config = MemConfig::builder().build(); + let mem_config = MemConfig::builder() + .common_config(CommonConfig::default()) + .build(); let mem = Mem::new(mem_config.clone()); mem.init().await.expect("initialzing memeory"); load_data( @@ -522,6 +524,8 @@ async fn GIVEN_data_dir_with_default_help_WHEN_mem_init_THEN_default_help_is_loa assert_eq!( notice .description + .as_ref() + .expect("no description!") .first() .expect("no description in notice"), "foo" @@ -569,6 +573,8 @@ async fn GIVEN_data_dir_with_host_help_WHEN_mem_init_THEN_host_help_is_loaded() assert_eq!( notice .description + .as_ref() + .expect("no description!") .first() .expect("no description in notice"), "bar" diff --git a/icann-rdap-srv/tests/integration/storage/mem/mod.rs b/icann-rdap-srv/tests/integration/storage/mem/mod.rs index b7811c6..e2d3028 100644 --- a/icann-rdap-srv/tests/integration/storage/mem/mod.rs +++ b/icann-rdap-srv/tests/integration/storage/mem/mod.rs @@ -10,7 +10,10 @@ use icann_rdap_common::response::{ types::{Common, Notice, NoticeOrRemark, ObjectCommon}, RdapResponse, }; -use icann_rdap_srv::storage::{mem::ops::Mem, StoreOps}; +use icann_rdap_srv::storage::{ + mem::{config::MemConfig, ops::Mem}, + CommonConfig, StoreOps, +}; use rstest::rstest; #[tokio::test] @@ -95,6 +98,79 @@ async fn GIVEN_domain_in_mem_WHEN_lookup_domain_by_unicode_THEN_domain_returned( ) } +#[tokio::test] +async fn GIVEN_domain_in_mem_WHEN_search_domain_by_name_THEN_domain_returned() { + // GIVEN + let mem = Mem::default(); + let mut tx = mem.new_tx().await.expect("new transaction"); + tx.add_domain( + &Domain::idn() + .unicode_name("foo.example.com") + .ldh_name("foo.example.com") + .build(), + ) + .await + .expect("add domain in tx"); + tx.commit().await.expect("tx commit"); + + // WHEN + let actual = mem + .search_domains_by_name("foo.example.*") + .await + .expect("getting domain by unicode"); + + // THEN + let RdapResponse::DomainSearchResults(domains) = actual else { + panic!() + }; + assert_eq!(domains.clone().results.len(), 1); + assert_eq!( + domains + .results + .first() + .expect("at least one") + .unicode_name + .as_ref() + .expect("unicodeName is none"), + "foo.example.com" + ) +} + +#[tokio::test] +async fn GIVEN_domain_in_mem_but_search_not_enabled_WHEN_search_domain_by_name_THEN_not_implemented( +) { + // GIVEN + let mem_config = MemConfig::builder() + .common_config( + CommonConfig::builder() + .domain_search_by_name_enable(false) + .build(), + ) + .build(); + let mem = Mem::new(mem_config); + let mut tx = mem.new_tx().await.expect("new transaction"); + tx.add_domain( + &Domain::idn() + .unicode_name("foo.example.com") + .ldh_name("foo.example.com") + .build(), + ) + .await + .expect("add domain in tx"); + tx.commit().await.expect("tx commit"); + + // WHEN + let actual = mem + .search_domains_by_name("foo.example.*") + .await + .expect("getting domain by unicode"); + + // THEN + let RdapResponse::ErrorResponse(_e) = actual else { + panic!() + }; +} + #[tokio::test] async fn GIVEN_no_domain_in_mem_WHEN_lookup_domain_by_ldh_THEN_404_returned() { // GIVEN @@ -568,6 +644,8 @@ async fn GIVEN_default_help_in_mem_WHEN_lookup_help_with_no_host_THEN_get_defaul assert_eq!( notice .description + .as_ref() + .expect("no description!") .first() .expect("no description in notice"), "foo" @@ -614,6 +692,8 @@ async fn GIVEN_help_in_mem_WHEN_lookup_help_with_host_THEN_get_host_help() { assert_eq!( notice .description + .as_ref() + .expect("no description") .first() .expect("no description in notice"), "bar" diff --git a/icann-rdap-srv/tests/integration/test_jig.rs b/icann-rdap-srv/tests/integration/test_jig.rs index 2584d4c..eb0e8d1 100644 --- a/icann-rdap-srv/tests/integration/test_jig.rs +++ b/icann-rdap-srv/tests/integration/test_jig.rs @@ -2,7 +2,9 @@ use assert_cmd::Command; use icann_rdap_srv::config::ListenConfig; use icann_rdap_srv::server::AppState; use icann_rdap_srv::server::Listener; +use icann_rdap_srv::storage::mem::config::MemConfig; use icann_rdap_srv::storage::mem::ops::Mem; +use icann_rdap_srv::storage::CommonConfig; use std::time::Duration; use test_dir::DirBuilder; use test_dir::TestDir; @@ -95,6 +97,27 @@ impl SrvTestJig { SrvTestJig { mem, rdap_base } } + pub async fn new_common_config(common_config: CommonConfig) -> SrvTestJig { + let mem_config = MemConfig::builder().common_config(common_config).build(); + let mem = Mem::new(mem_config); + let app_state = AppState { + storage: mem.clone(), + bootstrap: false, + }; + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + let listener = Listener::listen(&ListenConfig::default()) + .await + .expect("listening on interface"); + let rdap_base = listener.rdap_base(); + tokio::spawn(async move { + listener + .start_with_state(app_state) + .await + .expect("starting server"); + }); + SrvTestJig { mem, rdap_base } + } + pub async fn new_bootstrap() -> SrvTestJig { let mem = Mem::default(); let app_state = AppState {