1
0
Fork 0

Adding upstream version 0.0.22.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-08 18:41:54 +02:00
parent 2f814b513a
commit b06d3acde8
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
190 changed files with 61565 additions and 0 deletions

59
.github/workflows/build-test-rust.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: BuildTest
on:
push:
tags:
- 'bt_*'
jobs:
test-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check format
run: cargo fmt --check
- name: Build
run: cargo build --release --verbose
- name: Run tests
run: cargo test --verbose
- name: Install WASM32
run: rustup target add wasm32-unknown-unknown
- name: Check wasm32
run: cargo check --target wasm32-unknown-unknown -p icann-rdap-client
build:
name: Release build
needs: [test-build]
if: startsWith(github.ref, 'refs/tags/')
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
OS: ubuntu-latest
- target: aarch64-unknown-linux-gnu
OS: ubuntu-latest
- target: x86_64-apple-darwin
OS: macos-latest
- target: aarch64-apple-darwin
OS: macos-latest
- target: x86_64-pc-windows-msvc
OS: windows-latest
runs-on: ${{ matrix.OS }}
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Install rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
target: ${{ matrix.target }}
- name: Build target
uses: actions-rs/cargo@v1
with:
use-cross: true
command: build
args: --release --target ${{ matrix.target }}

75
.github/workflows/release-rust.yml vendored Normal file
View file

@ -0,0 +1,75 @@
name: Release
on:
pull_request:
push:
branches:
- main
tags:
- 'v*.*.*'
jobs:
test-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Check format
run: cargo fmt --check
- name: Build
run: cargo build --release --verbose
- name: Run tests
run: cargo test --verbose
- name: Install WASM32
run: rustup target add wasm32-unknown-unknown
- name: Check wasm32
run: cargo check --target wasm32-unknown-unknown -p icann-rdap-client
build:
name: Release build
needs: [test-build]
if: startsWith(github.ref, 'refs/tags/')
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
OS: ubuntu-latest
- target: aarch64-unknown-linux-gnu
OS: ubuntu-latest
- target: x86_64-apple-darwin
OS: macos-latest
- target: aarch64-apple-darwin
OS: macos-latest
- target: x86_64-pc-windows-msvc
OS: windows-latest
runs-on: ${{ matrix.OS }}
steps:
- name: Checkout
uses: actions/checkout@v1
- name: Install rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
target: ${{ matrix.target }}
- name: Build target
uses: actions-rs/cargo@v1
with:
use-cross: true
command: build
args: --release --target ${{ matrix.target }}
- name: Package
shell: bash
run: |
cd target/${{ matrix.target }}/release
tar czvf ../../../icann-rdap-${{ matrix.target }}.tar.gz rdap rdap-test rdap-srv rdap-srv-data rdap-srv-store rdap-srv-test-data
cd -
- name: Publish
uses: softprops/action-gh-release@v1
with:
files: 'icann-rdap-*'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
.env

4463
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

155
Cargo.toml Normal file
View file

@ -0,0 +1,155 @@
[workspace]
members = [
"icann-rdap-cli",
"icann-rdap-client",
"icann-rdap-common",
"icann-rdap-srv"
]
resolver = "2"
[workspace.package]
version = "0.0.22"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/icann/icann-rdap"
keywords = ["whois", "rdap"]
[workspace.dependencies]
# for suffix string searchs
ab-radix-trie = "0.2.1"
# easy error handling
anyhow = "1.0"
# async traits
async-trait = "0.1"
# axum (web server)
axum = { version = "0.7" }
axum-extra = { version = "0.9", features = [ "typed-header" ] }
axum-macros = "0.4"
# client IP address extractor
axum-client-ip = "0.5"
# b-tree with ranges
btree-range-map = "0.7.2"
# macros for the builder pattern
buildstructor = "0.5"
# CIDR utilities
cidr = "0.3.0"
# command line options parser
clap = { version = "4.4", features = [ "std", "derive", "env", "unstable-styles" ] }
# chrono (time and date library)
chrono = { version = "0.4", features = ["alloc", "std", "clock", "serde"], default-features = false }
# compile time constants formatting
const_format = "0.2"
# cross-platform application directories
directories = "5.0"
# loads environment variables from the a file
dotenv = "0.15.0"
# environment variable utilities
envmnt = "0.10.4"
# futures
futures = "0.3"
# futures-utils
futures-util = "0.3"
# macros to get the git version
git-version = "0.3"
# headers (http headers)
headers = "0.4"
# Hickory DNS client
hickory-client = "0.24"
# http constructs
http = "1.0"
# hyper (http implementation used by axum)
hyper = { version = "1.0", features = ["full"] }
# JSONPath
jsonpath-rust = "=0.5.0"
jsonpath_lib = "0.3.0"
# internationalized domain names for applications
idna = "1.0"
# for use prefixmap
ipnet = { version = "2.9", features = ["json"] }
# embedded pager
minus = {version = "5.5", features = ["dynamic_output", "search"] }
# percent encoding
pct-str = "1.2"
# ip address trie
prefix-trie = "0.2.4"
# regular expresions
regex = "1.10"
# http client library
reqwest = {version = "0.12", features = ["json", "stream", "native-tls-vendored"]}
# serialization / deserialization library
serde = { version = "1.0", features = [ "derive" ] }
# json serializer
serde_json = "1.0"
# sqlx (async db)
sqlx = { version = "0.7", features = [
"runtime-tokio-rustls",
"postgres",
"chrono",
"macros",
"json",
] }
# enum utilities
strum = "0.24"
strum_macros = "0.24"
# terminal markdown generator
termimad = "0.31"
# error macros
thiserror = "1.0"
# tokio async runtime
tokio = { version = "1.35", features = [ "full" ] }
# tower (tokio/axum middleware)
tower = { version = "0.4", features = ["timeout", "util"] }
tower-http = { version = "0.5", features = [
"add-extension",
"trace",
"cors",
] }
# tracing (logging)
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# url
url = "2.5"
[profile.release]
codegen-units = 1
lto = true

35
Cross.toml Normal file
View file

@ -0,0 +1,35 @@
[target.x86_64-unknown-linux-gnu]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
]
[target.x86_64-unknown-linux-musl]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
]
[target.aarch64-unknown-linux-gnu]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
]
[target.aarch64-unknown-linux-musl]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
]
[target.armv7-unknown-linux-gnueabihf]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
]
[target.armv7-unknown-linux-musleabihf]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update && apt-get install --assume-yes libssl-dev:$CROSS_DEB_ARCH"
]

202
LICENSE-APACHE Normal file
View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

21
LICENSE-MIT Normal file
View file

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2016 Ning Sun and tojson_macros contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

51
README.md Normal file
View file

@ -0,0 +1,51 @@
ICANN RDAP
==========
This repository contains open source code written by the Internet Corporation for Assigned Names and Numbers [(ICANN)](https://www.icann.org).
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 packages (i.e. Rust crates):
* [icann-rdap-cli](icann-rdap-cli/README.md) is the Command Line Interface client and testing tool.
* [icann-rdap-client](icann-rdap-client/README.md) is a Rust library handling making RDAP requests.
* [icann-rdap-common](icann-rdap-common/README.md) is a Rust library of RDAP structures.
* [icann-rdap-srv](icann-rdap-srv/README.md) is a simple, in-memory RDAP server. This package produces multiple executable binaries.
![Example of rdap command](https://github.com/icann/icann-rdap/wiki/images/rdap_command.png)
Installation and Usage
----------------------
See the [project wiki](https://github.com/icann/icann-rdap/wiki) for information on installation
and usage of this software.
License
-------
Licensed under either of
* Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT) at your option.
Contribution
------------
Unless you explicitly state otherwise, any contribution, as defined in the Apache-2.0 license,
intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license,
shall be dual licensed pursuant to the Apache License, Version 2.0 or the MIT License referenced
as above, at ICANNs option, without any additional terms or conditions.
How To Contribute
-----------------
Before working on a Pull Request (PR), seek advice from the maintainers regarding the acceptance
of the PR. To do this, submit an issue outlining the idea for the PR. If the maintainers agree
that the contribution would be welcome, they will assign the issue to you.
When submitting the PR, submit it against the 'dev' branch (not the 'main' branch).

58
icann-rdap-cli/Cargo.toml Normal file
View file

@ -0,0 +1,58 @@
[package]
name = "icann-rdap-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = """
An RDAP Command Line Interface client.
"""
[dependencies]
icann-rdap-client = { version = "0.0.22", path = "../icann-rdap-client" }
icann-rdap-common = { version = "0.0.22", path = "../icann-rdap-common" }
anyhow.workspace = true
clap.workspace = true
chrono.workspace = true
const_format.workspace = true
directories.workspace = true
dotenv.workspace = true
hickory-client.workspace = true
minus.workspace = true
pct-str.workspace = true
prefix-trie.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
strum_macros.workspace = true
termimad.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
url.workspace = true
[dev-dependencies]
icann-rdap-srv = { path = "../icann-rdap-srv" }
# cli assertions
assert_cmd = "2.0.11"
# CIDR utilities
cidr-utils = "0.5"
# fixture testings
rstest = "0.17.0"
# serial testings
serial_test = "2.0.0"
# test directories
test_dir = "0.2.0"
# tracing
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

37
icann-rdap-cli/README.md Normal file
View file

@ -0,0 +1,37 @@
ICANN RDAP CLI
==============
This package consists of the following commands:
* The [`rdap`](https://github.com/icann/icann-rdap/wiki/RDAP-command) command is a general-purpose RDAP command line client.
* The [`rdap-test`](https://github.com/icann/icann-rdap/wiki/RDAP-TEST-command) command is a testing tool for RDAP.
![Example of rdap command](https://github.com/icann/icann-rdap/wiki/images/rdap_command.png)
This is a command-line interface (CLI) client for the Registration Data Access Protocol (RDAP) written and sponsored
by the Internet Corporation for Assigned Names and Numbers [(ICANN)](https://www.icann.org).
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 and Usage
----------------------
See the [project wiki](https://github.com/icann/icann-rdap/wiki) for information on installation
and usage of this software.
License
-------
Licensed under either of
* Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT) at your option.
Contribution
------------
Unless you explicitly state otherwise, any contribution, as defined in the Apache-2.0 license,
intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license,
shall be dual licensed pursuant to the Apache License, Version 2.0 or the MIT License referenced
as above, at ICANNs option, without any additional terms or conditions.

View file

@ -0,0 +1,98 @@
use std::process::{ExitCode, Termination};
use {
icann_rdap_cli::rt::exec::TestExecutionError,
icann_rdap_client::{iana::IanaResponseError, RdapClientError},
thiserror::Error,
};
#[derive(Debug, Error)]
pub enum RdapTestError {
#[error("No errors encountered")]
Success,
#[error("Tests completed with execution errors.")]
TestsCompletedExecutionErrors,
#[error("Tests completed, warning checks found.")]
TestsCompletedWarningsFound,
#[error("Tests completed, error checks found.")]
TestsCompletedErrorsFound,
#[error(transparent)]
RdapClient(#[from] RdapClientError),
#[error(transparent)]
TestExecutionError(#[from] TestExecutionError),
#[error(transparent)]
Termimad(#[from] termimad::Error),
#[error(transparent)]
IoError(#[from] std::io::Error),
#[error("Unknown output type")]
UnknownOutputType,
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Iana(#[from] IanaResponseError),
#[error("Invalid IANA bootsrap file")]
InvalidBootstrap,
#[error("Bootstrap not found")]
BootstrapNotFound,
#[error("No registrar found")]
NoRegistrarFound,
#[error("No registry found")]
NoRegistryFound,
}
impl Termination for RdapTestError {
fn report(self) -> std::process::ExitCode {
let exit_code: u8 = match self {
// Success
Self::Success => 0,
Self::TestsCompletedExecutionErrors => 1,
Self::TestsCompletedWarningsFound => 2,
Self::TestsCompletedErrorsFound => 3,
// Internal Errors
Self::Termimad(_) => 10,
// I/O Errors
Self::IoError(_) => 40,
Self::TestExecutionError(_) => 40,
// RDAP Errors
Self::Json(_) => 100,
Self::Iana(_) => 101,
Self::InvalidBootstrap => 102,
Self::BootstrapNotFound => 103,
Self::NoRegistrarFound => 104,
Self::NoRegistryFound => 105,
// User Errors
Self::UnknownOutputType => 200,
// RDAP Client Errrors
Self::RdapClient(e) => match e {
// I/O Errors
RdapClientError::Client(_) => 42,
RdapClientError::IoError(_) => 43,
// RDAP Server Errors
RdapClientError::Response(_) => 60,
RdapClientError::ParsingError(_) => 62,
RdapClientError::Json(_) => 63,
// Bootstrap Errors
RdapClientError::BootstrapUnavailable => 70,
RdapClientError::BootstrapError(_) => 71,
RdapClientError::IanaResponse(_) => 72,
// User Errors
RdapClientError::InvalidQueryValue => 202,
RdapClientError::AmbiquousQueryType => 203,
RdapClientError::DomainNameError(_) => 204,
// Internal Errors
RdapClientError::Poison => 250,
// _ => 255,
},
};
ExitCode::from(exit_code)
}
}

View file

@ -0,0 +1,573 @@
use std::{io::stdout, str::FromStr};
#[cfg(debug_assertions)]
use tracing::warn;
use {
clap::builder::{styling::AnsiColor, Styles},
error::RdapTestError,
icann_rdap_cli::{
dirs,
dirs::fcbs::FileCacheBootstrapStore,
rt::{
exec::{execute_tests, ExtensionGroup, TestOptions},
results::{RunOutcome, TestResults},
},
},
icann_rdap_client::{http::ClientConfig, md::MdOptions, rdap::QueryType},
icann_rdap_common::check::{traverse_checks, CheckClass},
termimad::{crossterm::style::Color::*, Alignment, MadSkin},
tracing::info,
tracing_subscriber::filter::LevelFilter,
};
use {
clap::{Parser, ValueEnum},
icann_rdap_common::VERSION,
};
pub mod error;
struct CliStyles;
impl CliStyles {
fn cli_styles() -> Styles {
Styles::styled()
.header(AnsiColor::Yellow.on_default())
.usage(AnsiColor::Green.on_default())
.literal(AnsiColor::Green.on_default())
.placeholder(AnsiColor::Green.on_default())
}
}
#[derive(Parser, Debug)]
#[command(author, version = VERSION, about, long_about, styles = CliStyles::cli_styles())]
/// This program aids in the troubleshooting of issues with RDAP servers.
struct Cli {
/// Value to be queried in RDAP.
///
/// This is the value to query. For example, a domain name or IP address.
#[arg()]
query_value: String,
/// Output format.
///
/// This option determines the format of the result.
#[arg(
short = 'O',
long,
required = false,
env = "RDAP_TEST_OUTPUT",
value_enum,
default_value_t = OtypeArg::RenderedMarkdown,
)]
output_type: OtypeArg,
/// Check type.
///
/// Specifies the type of checks to conduct on the RDAP
/// responses. These are RDAP specific checks and not
/// JSON validation which is done automatically. This
/// argument may be specified multiple times to include
/// multiple check types.
#[arg(short = 'C', long, required = false, value_enum)]
check_type: Vec<CheckTypeArg>,
/// Log level.
///
/// This option determines the level of logging.
#[arg(
short = 'L',
long,
required = false,
env = "RDAP_TEST_LOG",
value_enum,
default_value_t = LogLevel::Info
)]
log_level: LogLevel,
/// DNS Resolver
///
/// Specifies the address and port of the DNS resolver to query.
#[arg(
long,
required = false,
env = "RDAP_TEST_DNS_RESOLVER",
default_value = "8.8.8.8:53"
)]
dns_resolver: String,
/// Allow HTTP connections.
///
/// When given, allows connections to RDAP servers using HTTP.
/// Otherwise, only HTTPS is allowed.
#[arg(short = 'T', long, required = false, env = "RDAP_TEST_ALLOW_HTTP")]
allow_http: bool,
/// Allow invalid host names.
///
/// When given, allows HTTPS connections to servers where the host name does
/// not match the certificate's host name.
#[arg(
short = 'K',
long,
required = false,
env = "RDAP_TEST_ALLOW_INVALID_HOST_NAMES"
)]
allow_invalid_host_names: bool,
/// Allow invalid certificates.
///
/// When given, allows HTTPS connections to servers where the TLS certificates
/// are invalid.
#[arg(
short = 'I',
long,
required = false,
env = "RDAP_TEST_ALLOW_INVALID_CERTIFICATES"
)]
allow_invalid_certificates: bool,
/// Maximum retry wait time.
///
/// Sets the maximum number of seconds to wait before retrying a query when
/// a server has sent an HTTP 429 status code with a retry-after value.
/// That is, the value to used is no greater than this setting.
#[arg(
long,
required = false,
env = "RDAP_TEST_MAX_RETRY_SECS",
default_value = "120"
)]
max_retry_secs: u32,
/// Default retry wait time.
///
/// Sets the number of seconds to wait before retrying a query when
/// a server has sent an HTTP 429 status code without a retry-after value
/// or when the retry-after value does not make sense.
#[arg(
long,
required = false,
env = "RDAP_TEST_DEF_RETRY_SECS",
default_value = "60"
)]
def_retry_secs: u32,
/// Maximum number of retries.
///
/// This sets the maximum number of retries when a server signals too many
/// requests have been sent using an HTTP 429 status code.
#[arg(
long,
required = false,
env = "RDAP_TEST_MAX_RETRIES",
default_value = "1"
)]
max_retries: u16,
/// Set the query timeout.
///
/// This values specifies, in seconds, the total time to connect and read all
/// the data from a connection.
#[arg(
long,
required = false,
env = "RDAP_TEST_TIMEOUT_SECS",
default_value = "60"
)]
timeout_secs: u64,
/// Skip v4.
///
/// Skip testing of IPv4 connections.
#[arg(long, required = false, env = "RDAP_TEST_SKIP_v4")]
skip_v4: bool,
/// Skip v6.
///
/// Skip testing of IPv6 connections.
#[arg(long, required = false, env = "RDAP_TEST_SKIP_V6")]
skip_v6: bool,
/// Skip origin tests.
///
/// Skip testing with the HTTP origin header.
#[arg(long, required = false, env = "RDAP_TEST_SKIP_ORIGIN")]
skip_origin: bool,
/// Only test one address.
///
/// Only test one address per address family.
#[arg(long, required = false, env = "RDAP_TEST_ONE_ADDR")]
one_addr: bool,
/// Origin header value.
///
/// Specifies the origin header value.
/// This value is not used if the 'skip-origin' option is used.
#[arg(
long,
required = false,
env = "RDAP_TEST_ORIGIN_VALUE",
default_value = "https://example.com"
)]
origin_value: String,
/// Follow redirects.
///
/// When set, follows HTTP redirects.
#[arg(
short = 'R',
long,
required = false,
env = "RDAP_TEST_FOLLOW_REDIRECTS"
)]
follow_redirects: bool,
/// Chase a referral.
///
/// Get a referral in the first response and use that for testing. This is useful
/// for testing registrars by using the normal bootstrapping process to get the
/// referral to the registrar from the registry.
#[arg(short = 'r', long, required = false)]
referral: bool,
/// Expect extension.
///
/// Expect the RDAP response to contain a specific extension ID.
/// If a response does not contain the expected RDAP extension ID,
/// it will be added as an failed check. This parameter may also
/// take the form of "foo1|foo2" to be mean either expect "foo1" or
/// "foo2".
///
/// This value may be repeated more than once.
#[arg(
short = 'e',
long,
required = false,
env = "RDAP_TEST_EXPECT_EXTENSIONS"
)]
expect_extensions: Vec<String>,
/// Expect extension group.
///
/// Extension groups are known sets of extensions.
///
/// This value may be repeated more than once.
#[arg(
short = 'g',
long,
required = false,
value_enum,
env = "RDAP_TEST_EXPECT_EXTENSION_GROUP"
)]
expect_group: Vec<ExtensionGroupArg>,
/// Allow unregistered extensions.
///
/// Do not flag unregistered extensions.
#[arg(
short = 'E',
long,
required = false,
env = "RDAP_TEST_ALLOW_UNREGISTERED_EXTENSIONS"
)]
allow_unregistered_extensions: bool,
}
/// Represents the output type possibilities.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum OtypeArg {
/// Results are rendered as Markdown in the terminal using ANSI terminal capabilities.
RenderedMarkdown,
/// Results are rendered as Markdown in plain text.
Markdown,
/// Results are output as RDAP JSON.
Json,
/// Results are output as Pretty RDAP JSON.
PrettyJson,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum CheckTypeArg {
/// All checks.
All,
/// Informational items.
Info,
/// Specification Notes
SpecNote,
/// Checks for STD 95 warnings.
StdWarn,
/// Checks for STD 95 errors.
StdError,
/// Cidr0 errors.
Cidr0Error,
/// ICANN Profile errors.
IcannError,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum ExtensionGroupArg {
/// The gTLD RDAP profiles.
Gtld,
/// The base NRO profiles.
Nro,
/// The NRO ASN profiles including the base profile.
NroAsn,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum LogLevel {
/// No logging.
Off,
/// Log errors.
Error,
/// Log errors and warnings.
Warn,
/// Log informational messages, errors, and warnings.
Info,
/// Log debug messages, informational messages, errors and warnings.
Debug,
/// Log messages appropriate for software development.
Trace,
}
impl From<&LogLevel> for LevelFilter {
fn from(log_level: &LogLevel) -> Self {
match log_level {
LogLevel::Off => Self::OFF,
LogLevel::Error => Self::ERROR,
LogLevel::Warn => Self::WARN,
LogLevel::Info => Self::INFO,
LogLevel::Debug => Self::DEBUG,
LogLevel::Trace => Self::TRACE,
}
}
}
#[tokio::main]
pub async fn main() -> RdapTestError {
if let Err(e) = wrapped_main().await {
eprintln!("\n{e}\n");
return e;
} else {
return RdapTestError::Success;
}
}
pub async fn wrapped_main() -> Result<(), RdapTestError> {
dirs::init()?;
dotenv::from_path(dirs::config_path()).ok();
let cli = Cli::parse();
let level = LevelFilter::from(&cli.log_level);
tracing_subscriber::fmt()
.with_max_level(level)
.with_writer(std::io::stderr)
.init();
info!("ICANN RDAP {} Testing Tool", VERSION);
#[cfg(debug_assertions)]
warn!("This is a development build of this software.");
let query_type = QueryType::from_str(&cli.query_value)?;
let check_classes = if cli.check_type.is_empty() {
vec![
CheckClass::StdWarning,
CheckClass::StdError,
CheckClass::Cidr0Error,
CheckClass::IcannError,
]
} else if cli.check_type.contains(&CheckTypeArg::All) {
vec![
CheckClass::Informational,
CheckClass::SpecificationNote,
CheckClass::StdWarning,
CheckClass::StdError,
CheckClass::Cidr0Error,
CheckClass::IcannError,
]
} else {
cli.check_type
.iter()
.map(|c| match c {
CheckTypeArg::Info => CheckClass::Informational,
CheckTypeArg::SpecNote => CheckClass::SpecificationNote,
CheckTypeArg::StdWarn => CheckClass::StdWarning,
CheckTypeArg::StdError => CheckClass::StdError,
CheckTypeArg::Cidr0Error => CheckClass::Cidr0Error,
CheckTypeArg::IcannError => CheckClass::IcannError,
CheckTypeArg::All => panic!("check type for all should have been handled."),
})
.collect::<Vec<CheckClass>>()
};
let mut expect_groups = vec![];
for g in cli.expect_group {
match g {
ExtensionGroupArg::Gtld => expect_groups.push(ExtensionGroup::Gtld),
ExtensionGroupArg::Nro => expect_groups.push(ExtensionGroup::Nro),
ExtensionGroupArg::NroAsn => expect_groups.push(ExtensionGroup::NroAsn),
}
}
let bs = FileCacheBootstrapStore;
let options = TestOptions {
skip_v4: cli.skip_v4,
skip_v6: cli.skip_v6,
skip_origin: cli.skip_origin,
origin_value: cli.origin_value,
chase_referral: cli.referral,
expect_extensions: cli.expect_extensions,
expect_groups,
allow_unregistered_extensions: cli.allow_unregistered_extensions,
one_addr: cli.one_addr,
dns_resolver: Some(cli.dns_resolver),
};
let client_config = ClientConfig::builder()
.user_agent_suffix("RT")
.https_only(!cli.allow_http)
.accept_invalid_host_names(cli.allow_invalid_host_names)
.accept_invalid_certificates(cli.allow_invalid_certificates)
.follow_redirects(cli.follow_redirects)
.timeout_secs(cli.timeout_secs)
.max_retry_secs(cli.max_retry_secs)
.def_retry_secs(cli.def_retry_secs)
.max_retries(cli.max_retries)
.build();
// execute tests
let test_results = execute_tests(&bs, &query_type, &options, &client_config).await?;
// output results
let md_options = MdOptions::default();
match cli.output_type {
OtypeArg::RenderedMarkdown => {
let mut skin = MadSkin::default_dark();
skin.set_headers_fg(Yellow);
skin.headers[1].align = Alignment::Center;
skin.headers[2].align = Alignment::Center;
skin.headers[3].align = Alignment::Center;
skin.headers[4].compound_style.set_fg(DarkGreen);
skin.headers[5].compound_style.set_fg(Magenta);
skin.headers[6].compound_style.set_fg(Cyan);
skin.headers[7].compound_style.set_fg(Red);
skin.bold.set_fg(DarkBlue);
skin.italic.set_fg(Red);
skin.quote_mark.set_fg(DarkBlue);
skin.table.set_fg(DarkGreen);
skin.table.align = Alignment::Center;
skin.inline_code.set_fgbg(Cyan, Reset);
skin.write_text_on(
&mut stdout(),
&test_results.to_md(&md_options, &check_classes),
)?;
}
OtypeArg::Markdown => {
println!("{}", test_results.to_md(&md_options, &check_classes));
}
OtypeArg::Json => {
println!("{}", serde_json::to_string(&test_results).unwrap());
}
OtypeArg::PrettyJson => {
println!("{}", serde_json::to_string_pretty(&test_results).unwrap());
}
}
// if some tests could not execute
//
let execution_errors = test_results
.test_runs
.iter()
.filter(|r| !matches!(r.outcome, RunOutcome::Tested | RunOutcome::Skipped))
.count();
if execution_errors != 0 {
return Err(RdapTestError::TestsCompletedExecutionErrors);
}
// if tests had check errors
//
// get the error classes but only if they were specified.
let error_classes = check_classes
.iter()
.filter(|c| {
matches!(
c,
CheckClass::StdError | CheckClass::Cidr0Error | CheckClass::IcannError
)
})
.copied()
.collect::<Vec<CheckClass>>();
// return proper exit code if errors found
if are_there_checks(error_classes, &test_results) {
return Err(RdapTestError::TestsCompletedErrorsFound);
}
// if tests had check warnings
//
// get the warning classes but only if they were specified.
let warning_classes = check_classes
.iter()
.filter(|c| matches!(c, CheckClass::StdWarning))
.copied()
.collect::<Vec<CheckClass>>();
// return proper exit code if errors found
if are_there_checks(warning_classes, &test_results) {
return Err(RdapTestError::TestsCompletedWarningsFound);
}
Ok(())
}
fn are_there_checks(classes: Vec<CheckClass>, test_results: &TestResults) -> bool {
// see if there are any checks in the test runs
let run_count = test_results
.test_runs
.iter()
.filter(|r| {
if let Some(checks) = &r.checks {
traverse_checks(checks, &classes, None, &mut |_, _| {})
} else {
false
}
})
.count();
// see if there are any classes in the service checks
let service_count = test_results
.service_checks
.iter()
.filter(|c| classes.contains(&c.check_class))
.count();
run_count + service_count != 0
}
#[cfg(test)]
mod tests {
use crate::Cli;
#[test]
fn cli_debug_assert_test() {
use clap::CommandFactory;
Cli::command().debug_assert()
}
}

View file

@ -0,0 +1,27 @@
Configuration:
Configuration of this program may also be set using an environment variables configuration file in the configuration directory of this program. An example is automatically written to the configuration directory. This configuraiton file may be customized by uncommenting out the provided environment variable settings.
The location of the configuration file is platform dependent.
On Linux, this file is located at $XDG_CONFIG_HOME/rdap/rdap.env or
$HOME/.config/rdap/rdap.env.
On macOS, this file is located at
$HOME/Library/Application Support/org.ICANN.rdap/rdap.env.
On Windows, this file is located at
{FOLDERID_RoamingAppData}\rdap\config\rdap.env.
Caches:
Cache data used by this program is kept in a location dependent on the platform:
On Linux, these files are located at $XDG_CACHE_HOME/rdap/ or
$HOME/.cache/rdap/.
On macOS, these files are located at
$HOME/Library/Caches/org.ICANN.rdap/.
On Windows, this file is located at
{FOLDERID_LocalAppData}\rdap\.

View file

@ -0,0 +1,4 @@
Copyright (C) 2023 Internet Corporation for Assigned Names and Numbers
This software is dual licensed using Apache License 2.0 and MIT License.
Information on this software may be found at https://github.com/icann/icann-rdap
Information on ICANN's RDAP program may be found at https://www.icann.org/rdap

View file

@ -0,0 +1,105 @@
use {
crate::error::RdapCliError,
icann_rdap_cli::dirs::fcbs::FileCacheBootstrapStore,
icann_rdap_client::{
http::Client,
iana::{fetch_bootstrap, qtype_to_bootstrap_url, BootstrapStore, PreferredUrl},
rdap::QueryType,
},
icann_rdap_common::iana::IanaRegistryType,
tracing::debug,
};
/// Defines the type of bootstrapping to use.
pub(crate) enum BootstrapType {
/// Use RFC 9224 bootstrapping.
///
/// This is the typical bootstrapping for RDAP as defined by RFC 9224.
Rfc9224,
/// Use the supplied URL.
///
/// Essentially, this means no bootstrapping as the client is being given
/// a full URL.
Url(String),
/// Use a hint.
///
/// This will try to find an authoritative server by cycling through the various
/// bootstrap registries in the following order: object tags, TLDs, IP addresses,
/// ASNs.
Hint(String),
}
pub(crate) async fn get_base_url(
bootstrap_type: &BootstrapType,
client: &Client,
query_type: &QueryType,
) -> Result<String, RdapCliError> {
if let QueryType::Url(url) = query_type {
// this is ultimately ignored without this logic a bootstrap not found error is thrown
// which is wrong for URL queries.
return Ok(url.to_owned());
}
let store = FileCacheBootstrapStore;
match bootstrap_type {
BootstrapType::Rfc9224 => Ok(qtype_to_bootstrap_url(client, &store, query_type, |reg| {
debug!("Fetching IANA registry {}", reg.url())
})
.await?),
BootstrapType::Url(url) => Ok(url.to_owned()),
BootstrapType::Hint(hint) => {
fetch_bootstrap(&IanaRegistryType::RdapObjectTags, client, &store, |_reg| {
debug!("Fetching IANA RDAP Object Tag Registry")
})
.await?;
if let Ok(urls) = store.get_tag_urls(hint) {
Ok(urls.preferred_url()?)
} else {
fetch_bootstrap(
&IanaRegistryType::RdapBootstrapDns,
client,
&store,
|_reg| debug!("Fetching IANA RDAP DNS Registry"),
)
.await?;
if let Ok(urls) = store.get_dns_urls(hint) {
Ok(urls.preferred_url()?)
} else {
fetch_bootstrap(
&IanaRegistryType::RdapBootstrapIpv4,
client,
&store,
|_reg| debug!("Fetching IANA RDAP IPv4 Registry"),
)
.await?;
if let Ok(urls) = store.get_ipv4_urls(hint) {
Ok(urls.preferred_url()?)
} else {
fetch_bootstrap(
&IanaRegistryType::RdapBootstrapIpv6,
client,
&store,
|_reg| debug!("Fetching IANA RDAP IPv6 Registry"),
)
.await?;
if let Ok(urls) = store.get_ipv6_urls(hint) {
Ok(urls.preferred_url()?)
} else {
fetch_bootstrap(
&IanaRegistryType::RdapBootstrapAsn,
client,
&store,
|_reg| debug!("Fetching IANA RDAP ASN Registry"),
)
.await?;
Ok(store.get_asn_urls(hint)?.preferred_url()?)
}
}
}
}
}
}
}

View file

@ -0,0 +1,108 @@
use std::process::{ExitCode, Termination};
use {
icann_rdap_client::{iana::IanaResponseError, RdapClientError},
minus::MinusError,
thiserror::Error,
tracing::error,
};
#[derive(Debug, Error)]
pub enum RdapCliError {
#[error("No errors encountered")]
Success,
#[error(transparent)]
RdapClient(#[from] RdapClientError),
#[error(transparent)]
Termimad(#[from] termimad::Error),
#[error(transparent)]
IoError(#[from] std::io::Error),
#[error(transparent)]
Minus(#[from] MinusError),
#[error("Unknown output type")]
UnknownOutputType,
#[error("RDAP response failed checks.")]
ErrorOnChecks,
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Iana(#[from] IanaResponseError),
#[error("Invalid IANA bootsrap file")]
InvalidBootstrap,
#[error("Bootstrap not found")]
BootstrapNotFound,
#[error("No registrar found")]
NoRegistrarFound,
#[error("No registry found")]
NoRegistryFound,
}
impl RdapCliError {
pub(crate) fn exit_code(&self) -> u8 {
match self {
// Success
Self::Success => 0,
// Internal Errors
Self::Termimad(_) => 10,
Self::Minus(_) => 11,
// I/O Errors
Self::IoError(_) => 40,
// RDAP Errors
Self::Json(_) => 100,
Self::Iana(_) => 101,
Self::InvalidBootstrap => 102,
Self::BootstrapNotFound => 103,
Self::NoRegistrarFound => 104,
Self::NoRegistryFound => 105,
// User Errors
Self::UnknownOutputType => 200,
Self::ErrorOnChecks => 201,
// RDAP Client Errrors
Self::RdapClient(e) => match e {
// I/O Errors
RdapClientError::Client(ce) => {
if ce.is_builder() {
match ce.url() {
Some(url) if url.scheme() == "http" => 202,
_ => 42,
}
} else {
42
}
}
RdapClientError::IoError(_) => 43,
// RDAP Server Errors
RdapClientError::Response(_) => 60,
RdapClientError::ParsingError(_) => 62,
RdapClientError::Json(_) => 63,
// Bootstrap Errors
RdapClientError::BootstrapUnavailable => 70,
RdapClientError::BootstrapError(_) => 71,
RdapClientError::IanaResponse(_) => 72,
// User Errors
RdapClientError::InvalidQueryValue => 202,
RdapClientError::AmbiquousQueryType => 203,
RdapClientError::DomainNameError(_) => 204,
// Internal Errors
RdapClientError::Poison => 250,
// _ => 255,
},
}
}
}
impl Termination for RdapCliError {
fn report(self) -> std::process::ExitCode {
let exit_code = self.exit_code();
ExitCode::from(exit_code)
}
}

View file

@ -0,0 +1,732 @@
#[cfg(debug_assertions)]
use tracing::warn;
use {
bootstrap::BootstrapType,
clap::builder::{styling::AnsiColor, Styles},
error::RdapCliError,
icann_rdap_cli::dirs,
icann_rdap_client::http::{create_client, Client, ClientConfig},
icann_rdap_common::check::CheckClass,
query::{InrBackupBootstrap, ProcessType, ProcessingParams, TldLookup},
std::{io::IsTerminal, str::FromStr},
tracing::{error, info},
tracing_subscriber::filter::LevelFilter,
write::{FmtWrite, PagerWrite},
};
use {
clap::{ArgGroup, Parser, ValueEnum},
icann_rdap_client::rdap::QueryType,
icann_rdap_common::VERSION,
query::OutputType,
tokio::{join, task::spawn_blocking},
};
use crate::query::do_query;
pub mod bootstrap;
pub mod error;
pub mod query;
pub mod request;
pub mod write;
const BEFORE_LONG_HELP: &str = include_str!("before_long_help.txt");
const AFTER_LONG_HELP: &str = include_str!("after_long_help.txt");
struct CliStyles;
impl CliStyles {
fn cli_styles() -> Styles {
Styles::styled()
.header(AnsiColor::Yellow.on_default())
.usage(AnsiColor::Green.on_default())
.literal(AnsiColor::Green.on_default())
.placeholder(AnsiColor::Green.on_default())
}
}
#[derive(Parser, Debug)]
#[command(author, version = VERSION, about, long_about, styles = CliStyles::cli_styles())]
#[command(group(
ArgGroup::new("input")
.required(true)
.args(["query_value", "server_help", "reset"]),
))]
#[command(group(
ArgGroup::new("base_specify")
.args(["base", "base_url"]),
))]
#[command(before_long_help(BEFORE_LONG_HELP))]
#[command(after_long_help(AFTER_LONG_HELP))]
/// This program queries network registry information from domain name registries and registrars
/// and Internet number registries (i.e. Regional Internet Registries) using the Registry Data
/// Access Protocol (RDAP).
struct Cli {
/// Value to be queried in RDAP.
///
/// This is the value to query. For example, a domain name or IP address.
#[arg()]
query_value: Option<String>,
/// Type of the query when using a query value.
///
/// Without this option, the query type will be inferred based on the query value.
/// To supress the infererence and explicitly specifty the query type, use this
/// option.
#[arg(
short = 't',
long,
requires = "query_value",
required = false,
value_enum
)]
query_type: Option<QtypeArg>,
/// Get an RDAP server's help information.
///
/// Ask for a server's help information.
#[arg(short = 'S', long, conflicts_with = "query_type")]
server_help: bool,
/// An RDAP base signifier.
///
/// This option gets a base URL from the RDAP bootstrap registries maintained
/// by IANA. For example, using "com" will get the base URL for the .com
/// registry, and "arin" will get the base URL for the RDAP tags registry,
/// which points to the ARIN RIR. This option checks the bootstrap registries
/// in the following order: object tags, TLDs, IPv4, IPv6, ASN.
#[arg(short = 'b', long, required = false, env = "RDAP_BASE")]
base: Option<String>,
/// An RDAP base URL for a specific RDAP server.
///
/// Use this option to explicitly give an RDAP base URL when issuing queries.
/// If not specified, the base URL will come from the RDAP boostrap process
/// outlined in RFC 9224.
#[arg(short = 'B', long, required = false, env = "RDAP_BASE_URL")]
base_url: Option<String>,
/// Specify where to send TLD queries.
///
/// Defaults to IANA.
#[arg(
long,
required = false,
env = "RDAP_TLD_LOOKUP",
value_enum,
default_value_t = TldLookupArg::Iana,
)]
tld_lookup: TldLookupArg,
/// Specify a backup INR bootstrap.
///
/// This is used as a backup when the bootstrapping process cannot find an authoritative
/// server for IP addresses and Autonomous System Numbers. Defaults to ARIN.
#[arg(
long,
required = false,
env = "RDAP_INR_BACKUP_BOOTSTRAP",
value_enum,
default_value_t = InrBackupBootstrapArg::Arin,
)]
inr_backup_bootstrap: InrBackupBootstrapArg,
/// Output format.
///
/// This option determines the format of the result.
#[arg(
short = 'O',
long,
required = false,
env = "RDAP_OUTPUT",
value_enum,
default_value_t = OtypeArg::Auto,
)]
output_type: OtypeArg,
/// Check type.
///
/// Specifies the type of checks to conduct on the RDAP
/// responses. These are RDAP specific checks and not
/// JSON validation which is done automatically. This
/// argument may be specified multiple times to include
/// multiple check types.
#[arg(short = 'C', long, required = false, value_enum)]
check_type: Vec<CheckTypeArg>,
/// Error if RDAP checks found.
///
/// The program will log error messages for non-info
/// checks found in the RDAP response(s) and exit with a
/// non-zero status.
#[arg(long, env = "RDAP_ERROR_ON_CHECK")]
error_on_checks: bool,
/// Process Type
///
/// Specifies a process for handling the data.
#[arg(
short = 'p',
long,
required = false,
env = "RDAP_PROCESS_TYPE",
value_enum
)]
process_type: Option<ProcTypeArg>,
/// Pager Usage.
///
/// Determines how to handle paging output.
/// When using the embedded pager, all log messages will be sent to the
/// pager as well. Otherwise, log messages are sent to stderr.
#[arg(
short = 'P',
long,
required = false,
env = "RDAP_PAGING",
value_enum,
default_value_t = PagerType::None,
)]
page_output: PagerType,
/// Log level.
///
/// This option determines the level of logging.
#[arg(
short = 'L',
long,
required = false,
env = "RDAP_LOG",
value_enum,
default_value_t = LogLevel::Info
)]
log_level: LogLevel,
/// Do not use the cache.
///
/// When given, the cache will be neither read from nor written to.
#[arg(short = 'N', long, required = false, env = "RDAP_NO_CACHE")]
no_cache: bool,
/// Max cache age.
///
/// Specifies the maximum age in seconds of an item in the cache.
#[arg(
long,
required = false,
env = "RDAP_MAX_CACHE_AGE",
default_value = "86400"
)]
max_cache_age: u32,
/// Allow HTTP connections.
///
/// When given, allows connections to RDAP servers using HTTP.
/// Otherwise, only HTTPS is allowed.
#[arg(short = 'T', long, required = false, env = "RDAP_ALLOW_HTTP")]
allow_http: bool,
/// Allow invalid host names.
///
/// When given, allows HTTPS connections to servers where the host name does
/// not match the certificate's host name.
#[arg(
short = 'K',
long,
required = false,
env = "RDAP_ALLOW_INVALID_HOST_NAMES"
)]
allow_invalid_host_names: bool,
/// Allow invalid certificates.
///
/// When given, allows HTTPS connections to servers where the TLS certificates
/// are invalid.
#[arg(
short = 'I',
long,
required = false,
env = "RDAP_ALLOW_INVALID_CERTIFICATES"
)]
allow_invalid_certificates: bool,
/// Set the query timeout.
///
/// This values specifies, in seconds, the total time to connect and read all
/// the data from a connection.
#[arg(
long,
required = false,
env = "RDAP_TIMEOUT_SECS",
default_value = "60"
)]
timeout_secs: u64,
/// Maximum retry wait time.
///
/// Sets the maximum number of seconds to wait before retrying a query when
/// a server has sent an HTTP 429 status code with a retry-after value.
/// That is, the value to used is no greater than this setting.
#[arg(
long,
required = false,
env = "RDAP_MAX_RETRY_SECS",
default_value = "120"
)]
max_retry_secs: u32,
/// Default retry wait time.
///
/// Sets the number of seconds to wait before retrying a query when
/// a server has sent an HTTP 429 status code without a retry-after value
/// or when the retry-after value does not make sense.
#[arg(
long,
required = false,
env = "RDAP_DEF_RETRY_SECS",
default_value = "60"
)]
def_retry_secs: u32,
/// Maximum number of retries.
///
/// This sets the maximum number of retries when a server signals too many
/// requests have been sent using an HTTP 429 status code.
#[arg(long, required = false, env = "RDAP_MAX_RETRIES", default_value = "1")]
max_retries: u16,
/// Reset.
///
/// Removes the cache files and resets the config file.
#[arg(long, required = false)]
reset: bool,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum QtypeArg {
/// Ipv4 Address Lookup
V4,
/// Ipv6 Address Lookup
V6,
/// Ipv4 CIDR Lookup
V4Cidr,
/// Ipv6 CIDR Lookup
V6Cidr,
/// Autonomous System Number Lookup
Autnum,
/// Domain Lookup
Domain,
/// A-Label Domain Lookup
ALabel,
/// Entity Lookup
Entity,
/// Nameserver Lookup
Ns,
/// Entity Name Search
EntityName,
/// Entity Handle Search
EntityHandle,
/// Domain Name Search
DomainName,
/// Domain Nameserver Name Search
DomainNsName,
/// Domain Nameserver IP Address Search
DomainNsIp,
/// Nameserver Name Search
NsName,
/// Nameserver IP Address Search
NsIp,
/// RDAP URL
Url,
}
/// Represents the output type possibilities.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum OtypeArg {
/// Results are rendered as Markdown in the terminal using ANSI terminal capabilities.
RenderedMarkdown,
/// Results are rendered as Markdown in plain text.
Markdown,
/// Results are output as RDAP JSON.
Json,
/// Results are output as Pretty RDAP JSON.
PrettyJson,
/// RDAP JSON with extra information.
JsonExtra,
/// Global Top Level Domain Output
GtldWhois,
/// URL of RDAP servers.
Url,
/// Automatically determine the output type.
Auto,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum CheckTypeArg {
/// All checks.
All,
/// Informational items.
Info,
/// Specification Notes
SpecNote,
/// Checks for STD 95 warnings.
StdWarn,
/// Checks for STD 95 errors.
StdError,
/// Cidr0 errors.
Cidr0Error,
/// ICANN Profile errors.
IcannError,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum LogLevel {
/// No logging.
Off,
/// Log errors.
Error,
/// Log errors and warnings.
Warn,
/// Log informational messages, errors, and warnings.
Info,
/// Log debug messages, informational messages, errors and warnings.
Debug,
/// Log messages appropriate for software development.
Trace,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum ProcTypeArg {
/// Only display the data from the domain registrar.
Registrar,
/// Only display the data from the domain registry.
Registry,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum PagerType {
/// Use the embedded pager.
Embedded,
/// Use no pager.
None,
/// Automatically determine pager use.
Auto,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum TldLookupArg {
/// Use IANA for TLD lookups.
Iana,
/// No TLD specific lookups.
None,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum InrBackupBootstrapArg {
/// Use ARIN when no INR bootstrap can be found.
Arin,
/// No backup for INR bootstraps.
None,
}
impl From<&LogLevel> for LevelFilter {
fn from(log_level: &LogLevel) -> Self {
match log_level {
LogLevel::Off => Self::OFF,
LogLevel::Error => Self::ERROR,
LogLevel::Warn => Self::WARN,
LogLevel::Info => Self::INFO,
LogLevel::Debug => Self::DEBUG,
LogLevel::Trace => Self::TRACE,
}
}
}
#[tokio::main]
pub async fn main() -> RdapCliError {
if let Err(e) = wrapped_main().await {
let ec = e.exit_code();
match ec {
202 => error!("Use -T or --allow-http to allow insecure HTTP connections."),
_ => eprintln!("\n{e}\n"),
};
return e;
} else {
return RdapCliError::Success;
}
}
pub async fn wrapped_main() -> Result<(), RdapCliError> {
dirs::init()?;
dotenv::from_path(dirs::config_path()).ok();
let cli = Cli::parse();
if cli.reset {
dirs::reset()?;
return Ok(());
}
let level = LevelFilter::from(&cli.log_level);
let query_type = query_type_from_cli(&cli)?;
let use_pager = match cli.page_output {
PagerType::Embedded => true,
PagerType::None => false,
PagerType::Auto => std::io::stdout().is_terminal(),
};
let output_type = match cli.output_type {
OtypeArg::Auto => {
if std::io::stdout().is_terminal() {
OutputType::RenderedMarkdown
} else {
OutputType::Json
}
}
OtypeArg::RenderedMarkdown => OutputType::RenderedMarkdown,
OtypeArg::Markdown => OutputType::Markdown,
OtypeArg::Json => OutputType::Json,
OtypeArg::PrettyJson => OutputType::PrettyJson,
OtypeArg::JsonExtra => OutputType::JsonExtra,
OtypeArg::GtldWhois => OutputType::GtldWhois,
OtypeArg::Url => OutputType::Url,
};
let process_type = match cli.process_type {
Some(p) => match p {
ProcTypeArg::Registrar => ProcessType::Registrar,
ProcTypeArg::Registry => ProcessType::Registry,
},
None => ProcessType::Standard,
};
let check_types = if cli.check_type.is_empty() {
vec![
CheckClass::Informational,
CheckClass::StdWarning,
CheckClass::StdError,
CheckClass::Cidr0Error,
CheckClass::IcannError,
]
} else if cli.check_type.contains(&CheckTypeArg::All) {
vec![
CheckClass::Informational,
CheckClass::SpecificationNote,
CheckClass::StdWarning,
CheckClass::StdError,
CheckClass::Cidr0Error,
CheckClass::IcannError,
]
} else {
cli.check_type
.iter()
.map(|c| match c {
CheckTypeArg::Info => CheckClass::Informational,
CheckTypeArg::SpecNote => CheckClass::SpecificationNote,
CheckTypeArg::StdWarn => CheckClass::StdWarning,
CheckTypeArg::StdError => CheckClass::StdError,
CheckTypeArg::Cidr0Error => CheckClass::Cidr0Error,
CheckTypeArg::IcannError => CheckClass::IcannError,
CheckTypeArg::All => panic!("check type for all should have been handled."),
})
.collect::<Vec<CheckClass>>()
};
let bootstrap_type = if let Some(ref tag) = cli.base {
BootstrapType::Hint(tag.to_string())
} else if let Some(ref base_url) = cli.base_url {
BootstrapType::Url(base_url.to_string())
} else {
BootstrapType::Rfc9224
};
let tld_lookup = match cli.tld_lookup {
TldLookupArg::Iana => TldLookup::Iana,
TldLookupArg::None => TldLookup::None,
};
let inr_backup_bootstrap = match cli.inr_backup_bootstrap {
InrBackupBootstrapArg::Arin => InrBackupBootstrap::Arin,
InrBackupBootstrapArg::None => InrBackupBootstrap::None,
};
let processing_params = ProcessingParams {
bootstrap_type,
output_type,
check_types,
process_type,
tld_lookup,
inr_backup_bootstrap,
error_on_checks: cli.error_on_checks,
no_cache: cli.no_cache,
max_cache_age: cli.max_cache_age,
};
let client_config = ClientConfig::builder()
.user_agent_suffix("CLI")
.https_only(!cli.allow_http)
.accept_invalid_host_names(cli.allow_invalid_host_names)
.accept_invalid_certificates(cli.allow_invalid_certificates)
.timeout_secs(cli.timeout_secs)
.max_retry_secs(cli.max_retry_secs)
.def_retry_secs(cli.def_retry_secs)
.max_retries(cli.max_retries)
.build();
let rdap_client = create_client(&client_config);
if let Ok(client) = rdap_client {
if !use_pager {
tracing_subscriber::fmt()
.with_max_level(level)
.with_writer(std::io::stderr)
.init();
let output = &mut std::io::stdout();
let res1 = join!(exec(
cli.query_value,
&query_type,
&processing_params,
&client,
output,
));
res1.0?;
} else {
let pager = minus::Pager::new();
pager
.set_prompt(format!(
"{query_type} - Q to quit, j/k or pgup/pgdn to scroll"
))
.expect("unable to set prompt");
let output = FmtWrite(pager.clone());
let pager2 = pager.clone();
tracing_subscriber::fmt()
.with_max_level(level)
.with_writer(move || -> Box<dyn std::io::Write> {
Box::new(PagerWrite(pager2.clone()))
})
.init();
let pager = pager.clone();
let (res1, res2) = join!(
spawn_blocking(move || minus::dynamic_paging(pager)),
exec(
cli.query_value,
&query_type,
&processing_params,
&client,
output
)
);
res1.unwrap()?;
res2?;
}
} else {
error!("{}", rdap_client.err().unwrap())
};
Ok(())
}
async fn exec<W: std::io::Write>(
query_value: Option<String>,
query_type: &QueryType,
processing_params: &ProcessingParams,
client: &Client,
mut output: W,
) -> Result<(), RdapCliError> {
info!("ICANN RDAP {} Command Line Interface", VERSION);
#[cfg(debug_assertions)]
warn!("This is a development build of this software.");
if let Some(query_value) = query_value {
info!("query type is {query_type} for value '{}'", query_value);
} else {
info!("query is {query_type}");
}
let result = do_query(query_type, processing_params, client, &mut output).await;
match result {
Ok(_) => Ok(()),
Err(error) => {
error!("{}", error);
Err(error)
}
}
}
fn query_type_from_cli(cli: &Cli) -> Result<QueryType, RdapCliError> {
let Some(query_value) = cli.query_value.clone() else {
return Ok(QueryType::Help);
};
let Some(query_type) = cli.query_type else {
return Ok(QueryType::from_str(&query_value)?);
};
let q = match query_type {
QtypeArg::V4 => QueryType::ipv4(&query_value)?,
QtypeArg::V6 => QueryType::ipv6(&query_value)?,
QtypeArg::V4Cidr => QueryType::ipv4cidr(&query_value)?,
QtypeArg::V6Cidr => QueryType::ipv6cidr(&query_value)?,
QtypeArg::Autnum => QueryType::autnum(&query_value)?,
QtypeArg::Domain => QueryType::domain(&query_value)?,
QtypeArg::ALabel => QueryType::alabel(&query_value)?,
QtypeArg::Entity => QueryType::Entity(query_value),
QtypeArg::Ns => QueryType::ns(&query_value)?,
QtypeArg::EntityName => QueryType::EntityNameSearch(query_value),
QtypeArg::EntityHandle => QueryType::EntityHandleSearch(query_value),
QtypeArg::DomainName => QueryType::DomainNameSearch(query_value),
QtypeArg::DomainNsName => QueryType::DomainNsNameSearch(query_value),
QtypeArg::DomainNsIp => QueryType::domain_ns_ip_search(&query_value)?,
QtypeArg::NsName => QueryType::NameserverNameSearch(query_value),
QtypeArg::NsIp => QueryType::ns_ip_search(&query_value)?,
QtypeArg::Url => QueryType::Url(query_value),
};
Ok(q)
}
#[cfg(test)]
mod tests {
use crate::Cli;
#[test]
fn cli_debug_assert_test() {
use clap::CommandFactory;
Cli::command().debug_assert()
}
}

View file

@ -0,0 +1,479 @@
use {
icann_rdap_client::http::Client,
icann_rdap_common::{
check::{traverse_checks, CheckClass, CheckParams, Checks, GetChecks},
response::get_related_links,
},
tracing::{debug, error, info},
};
use {
icann_rdap_client::{
gtld::{GtldParams, ToGtldWhois},
md::{redacted::replace_redacted_items, MdOptions, MdParams, ToMd},
rdap::{
QueryType, RequestData, RequestResponse, RequestResponses, ResponseData, SourceType,
},
},
termimad::{crossterm::style::Color::*, Alignment, MadSkin},
};
use crate::{
bootstrap::{get_base_url, BootstrapType},
error::RdapCliError,
request::do_request,
};
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum OutputType {
/// Results are rendered as Markdown in the terminal using ANSI terminal capabilities.
RenderedMarkdown,
/// Results are rendered as Markdown in plain text.
Markdown,
/// Results are output as RDAP JSON.
Json,
/// Results are output as Pretty RDAP JSON.
PrettyJson,
/// Global Top Level Domain Output
GtldWhois,
/// RDAP JSON with extra information.
JsonExtra,
/// URL
Url,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum ProcessType {
/// Standard data processing.
Standard,
/// Process data specifically from a registrar.
Registrar,
/// Process data specifically from a registry.
Registry,
}
/// Used for doing TLD Lookups.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum TldLookup {
/// Use IANA for TLD lookups.
Iana,
/// No TLD specific lookups.
None,
}
/// Used for doing TLD Lookups.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum InrBackupBootstrap {
/// Use ARIN if no bootstraps can be found for INR queries.
Arin,
/// No INR bootstrap backup.
None,
}
pub(crate) struct ProcessingParams {
pub bootstrap_type: BootstrapType,
pub output_type: OutputType,
pub check_types: Vec<CheckClass>,
pub process_type: ProcessType,
pub tld_lookup: TldLookup,
pub inr_backup_bootstrap: InrBackupBootstrap,
pub error_on_checks: bool,
pub no_cache: bool,
pub max_cache_age: u32,
}
pub(crate) async fn do_query<'a, W: std::io::Write>(
query_type: &QueryType,
processing_params: &ProcessingParams,
client: &Client,
write: &mut W,
) -> Result<(), RdapCliError> {
match query_type {
QueryType::IpV4Addr(_)
| QueryType::IpV6Addr(_)
| QueryType::IpV4Cidr(_)
| QueryType::IpV6Cidr(_)
| QueryType::AsNumber(_) => {
do_inr_query(query_type, processing_params, client, write).await
}
QueryType::Domain(_) | QueryType::DomainNameSearch(_) => {
do_domain_query(query_type, processing_params, client, write).await
}
_ => do_basic_query(query_type, processing_params, None, client, write).await,
}
}
async fn do_domain_query<'a, W: std::io::Write>(
query_type: &QueryType,
processing_params: &ProcessingParams,
client: &Client,
write: &mut W,
) -> Result<(), RdapCliError> {
let mut transactions = RequestResponses::new();
// special processing for TLD Lookups
let base_url = if let QueryType::Domain(ref domain) = query_type {
if domain.is_tld() && matches!(processing_params.tld_lookup, TldLookup::Iana) {
"https://rdap.iana.org".to_string()
} else {
get_base_url(&processing_params.bootstrap_type, client, query_type).await?
}
} else {
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();
let req_data = RequestData {
req_number: 1,
source_host: &source_host,
source_type: SourceType::DomainRegistry,
};
let replaced_rdap = replace_redacted_items(response.rdap.clone());
let replaced_data = ResponseData {
rdap: replaced_rdap,
// copy other fields from `response`
..response.clone()
};
if let ProcessType::Registrar = processing_params.process_type {
transactions =
do_no_output(processing_params, &req_data, &replaced_data, transactions);
} else {
transactions = do_output(
processing_params,
&req_data,
&replaced_data,
write,
transactions,
)?;
}
let regr_source_host;
let regr_req_data: RequestData;
if !matches!(processing_params.process_type, ProcessType::Registry) {
if let Some(url) = get_related_links(&response.rdap).first() {
info!("Querying domain name from registrar.");
debug!("Registrar RDAP Url: {url}");
let query_type = QueryType::Url(url.to_string());
let registrar_response_result =
do_request(&base_url, &query_type, processing_params, client).await;
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: &regr_source_host,
source_type: SourceType::DomainRegistrar,
};
if let ProcessType::Registry = processing_params.process_type {
transactions = do_no_output(
processing_params,
&regr_req_data,
&registrar_response,
transactions,
);
} else {
transactions = do_output(
processing_params,
&regr_req_data,
&registrar_response,
write,
transactions,
)?;
}
}
Err(error) => return Err(error),
}
} else if matches!(processing_params.process_type, ProcessType::Registrar) {
return Err(RdapCliError::NoRegistrarFound);
}
}
do_final_output(processing_params, write, transactions)?;
}
Err(error) => {
if matches!(processing_params.process_type, ProcessType::Registry) {
return Err(RdapCliError::NoRegistryFound);
} else {
return Err(error);
}
}
};
Ok(())
}
async fn do_inr_query<'a, W: std::io::Write>(
query_type: &QueryType,
processing_params: &ProcessingParams,
client: &Client,
write: &mut W,
) -> Result<(), RdapCliError> {
let mut transactions = RequestResponses::new();
let mut base_url = get_base_url(&processing_params.bootstrap_type, client, query_type).await;
if base_url.is_err()
&& matches!(
processing_params.inr_backup_bootstrap,
InrBackupBootstrap::Arin
)
{
base_url = Ok("https://rdap.arin.net/registry".to_string());
};
let response = do_request(&base_url?, query_type, processing_params, client).await;
match response {
Ok(response) => {
let source_host = response.http_data.host.to_owned();
let req_data = RequestData {
req_number: 1,
source_host: &source_host,
source_type: SourceType::RegionalInternetRegistry,
};
let replaced_rdap = replace_redacted_items(response.rdap.clone());
let replaced_data = ResponseData {
rdap: replaced_rdap,
// copy other fields from `response`
..response.clone()
};
transactions = do_output(
processing_params,
&req_data,
&replaced_data,
write,
transactions,
)?;
do_final_output(processing_params, write, transactions)?;
}
Err(error) => return Err(error),
};
Ok(())
}
async fn do_basic_query<'a, W: std::io::Write>(
query_type: &QueryType,
processing_params: &ProcessingParams,
req_data: Option<&'a RequestData<'a>>,
client: &Client,
write: &mut W,
) -> Result<(), RdapCliError> {
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;
match response {
Ok(response) => {
let source_host = response.http_data.host.to_owned();
let req_data = if let Some(meta) = req_data {
RequestData {
req_number: meta.req_number + 1,
source_host: meta.source_host,
source_type: SourceType::UncategorizedRegistry,
}
} else {
RequestData {
req_number: 1,
source_host: &source_host,
source_type: SourceType::UncategorizedRegistry,
}
};
let replaced_rdap = replace_redacted_items(response.rdap.clone());
let replaced_data = ResponseData {
rdap: replaced_rdap,
// copy other fields from `response`
..response.clone()
};
transactions = do_output(
processing_params,
&req_data,
&replaced_data,
write,
transactions,
)?;
do_final_output(processing_params, write, transactions)?;
}
Err(error) => return Err(error),
};
Ok(())
}
fn do_output<'a, W: std::io::Write>(
processing_params: &ProcessingParams,
req_data: &'a RequestData,
response: &'a ResponseData,
write: &mut W,
mut transactions: RequestResponses<'a>,
) -> Result<RequestResponses<'a>, RdapCliError> {
match processing_params.output_type {
OutputType::RenderedMarkdown => {
let mut skin = MadSkin::default_dark();
skin.set_headers_fg(Yellow);
skin.headers[1].align = Alignment::Center;
skin.headers[2].align = Alignment::Center;
skin.headers[3].align = Alignment::Center;
skin.headers[4].compound_style.set_fg(DarkGreen);
skin.headers[5].compound_style.set_fg(Magenta);
skin.headers[6].compound_style.set_fg(Cyan);
skin.headers[7].compound_style.set_fg(Red);
skin.bold.set_fg(DarkBlue);
skin.italic.set_fg(Red);
skin.quote_mark.set_fg(DarkBlue);
skin.table.set_fg(DarkGreen);
skin.table.align = Alignment::Center;
skin.inline_code.set_fgbg(Cyan, Reset);
skin.write_text_on(
write,
&response.rdap.to_md(MdParams {
heading_level: 1,
root: &response.rdap,
http_data: &response.http_data,
parent_type: response.rdap.get_type(),
check_types: &processing_params.check_types,
options: &MdOptions::default(),
req_data,
}),
)?;
}
OutputType::Markdown => {
writeln!(
write,
"{}",
response.rdap.to_md(MdParams {
heading_level: 1,
root: &response.rdap,
http_data: &response.http_data,
parent_type: response.rdap.get_type(),
check_types: &processing_params.check_types,
options: &MdOptions {
text_style_char: '_',
style_in_justify: true,
..MdOptions::default()
},
req_data,
})
)?;
}
OutputType::GtldWhois => {
let mut params = GtldParams {
root: &response.rdap,
parent_type: response.rdap.get_type(),
label: "".to_string(),
};
writeln!(write, "{}", response.rdap.to_gtld_whois(&mut params))?;
}
_ => {} // do nothing
};
let req_res = RequestResponse {
checks: do_output_checks(response),
req_data,
res_data: response,
};
transactions.push(req_res);
Ok(transactions)
}
fn do_no_output<'a>(
_processing_params: &ProcessingParams,
req_data: &'a RequestData,
response: &'a ResponseData,
mut transactions: RequestResponses<'a>,
) -> RequestResponses<'a> {
let req_res = RequestResponse {
checks: do_output_checks(response),
req_data,
res_data: response,
};
transactions.push(req_res);
transactions
}
fn do_output_checks(response: &ResponseData) -> Checks {
let check_params = CheckParams {
do_subchecks: true,
root: &response.rdap,
parent_type: response.rdap.get_type(),
allow_unreg_ext: false,
};
let mut checks = response.rdap.get_checks(check_params);
checks
.items
.append(&mut response.http_data.get_checks(check_params).items);
checks
}
fn do_final_output<W: std::io::Write>(
processing_params: &ProcessingParams,
write: &mut W,
transactions: RequestResponses<'_>,
) -> Result<(), RdapCliError> {
match processing_params.output_type {
OutputType::Json => {
for req_res in &transactions {
writeln!(
write,
"{}",
serde_json::to_string(&req_res.res_data.rdap).unwrap()
)?;
}
}
OutputType::PrettyJson => {
for req_res in &transactions {
writeln!(
write,
"{}",
serde_json::to_string_pretty(&req_res.res_data.rdap).unwrap()
)?;
}
}
OutputType::JsonExtra => {
writeln!(write, "{}", serde_json::to_string(&transactions).unwrap())?
}
OutputType::GtldWhois => {}
OutputType::Url => {
for rr in &transactions {
if let Some(url) = rr.res_data.http_data.request_uri() {
writeln!(write, "{url}")?;
}
}
}
_ => {} // do nothing
};
let mut checks_found = false;
// we don't want to error on informational
let error_check_types: Vec<CheckClass> = processing_params
.check_types
.iter()
.filter(|ct| *ct != &CheckClass::Informational)
.copied()
.collect();
for req_res in &transactions {
let found = traverse_checks(
&req_res.checks,
&error_check_types,
None,
&mut |struct_tree, check_item| {
if processing_params.error_on_checks {
error!("{struct_tree} -> {check_item}")
}
},
);
if found {
checks_found = true
}
}
if checks_found && processing_params.error_on_checks {
return Err(RdapCliError::ErrorOnChecks);
}
Ok(())
}

View file

@ -0,0 +1,88 @@
use std::{
fs::{self, File},
io::{BufRead, BufReader},
};
use {
icann_rdap_client::{
http::Client,
rdap::{rdap_url_request, QueryType, ResponseData},
},
icann_rdap_common::{httpdata::HttpData, response::GetSelfLink},
pct_str::{PctString, URIReserved},
tracing::{debug, info},
};
use crate::{dirs::rdap_cache_path, error::RdapCliError, query::ProcessingParams};
pub(crate) async fn do_request(
base_url: &str,
query_type: &QueryType,
processing_params: &ProcessingParams,
client: &Client,
) -> Result<ResponseData, RdapCliError> {
if processing_params.no_cache {
info!("Cache has been disabled.")
}
let query_url = query_type.query_url(base_url)?;
if !processing_params.no_cache {
let file_name = format!(
"{}.cache",
PctString::encode(query_url.chars(), URIReserved)
);
let path = rdap_cache_path().join(&file_name);
if path.exists() {
let input = File::open(path)?;
let buf = BufReader::new(input);
let mut lines = vec![];
for line in buf.lines() {
lines.push(line?)
}
let cache_data = HttpData::from_lines(&lines)?;
if !cache_data
.0
.is_expired(processing_params.max_cache_age as i64)
{
debug!("Returning response from cache file {file_name}");
let response: ResponseData = serde_json::from_str(&cache_data.1.join(""))?;
return Ok(response);
}
}
}
let response = rdap_url_request(&query_url, client).await?;
if !processing_params.no_cache {
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 query response to cache file {file_name}");
let path = rdap_cache_path().join(file_name);
fs::write(path, &cache_contents)?;
if let Some(self_link) = response.rdap.get_self_link() {
if let Some(self_link_href) = &self_link.href {
if query_url != *self_link_href {
let file_name = format!(
"{}.cache",
PctString::encode(self_link_href.chars(), URIReserved)
);
debug!("Saving object with self link 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
);
}
}
Ok(response)
}

View file

@ -0,0 +1,35 @@
use std::io::ErrorKind;
use minus::Pager;
#[derive(Clone)]
pub(crate) struct FmtWrite<W: std::fmt::Write>(pub(crate) W);
impl<W: std::fmt::Write> std::io::Write for FmtWrite<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0
.write_str(&String::from_utf8_lossy(buf))
.map_err(|e| std::io::Error::new(ErrorKind::Other, e))?;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[derive(Clone)]
pub(crate) struct PagerWrite(pub(crate) Pager);
impl std::io::Write for PagerWrite {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0
.push_str(String::from_utf8_lossy(buf))
.map_err(|e| std::io::Error::new(ErrorKind::Other, e))?;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}

View file

@ -0,0 +1,389 @@
use std::{
fs::{self, File},
io::{BufRead, BufReader},
path::PathBuf,
};
use {
icann_rdap_client::iana::{BootstrapStore, RegistryHasNotExpired},
icann_rdap_common::{
httpdata::HttpData,
iana::{BootstrapRegistry, IanaRegistry, IanaRegistryType},
},
tracing::debug,
};
use super::bootstrap_cache_path;
pub struct FileCacheBootstrapStore;
impl BootstrapStore for FileCacheBootstrapStore {
fn has_bootstrap_registry(
&self,
reg_type: &IanaRegistryType,
) -> Result<bool, icann_rdap_client::RdapClientError> {
let path = bootstrap_cache_path().join(reg_type.file_name());
if path.exists() {
let fc_reg = fetch_file_cache_bootstrap(path, |s| debug!("Checking for {s}"))?;
return Ok(Some(fc_reg).registry_has_not_expired());
}
Ok(false)
}
fn put_bootstrap_registry(
&self,
reg_type: &IanaRegistryType,
registry: IanaRegistry,
http_data: HttpData,
) -> Result<(), icann_rdap_client::RdapClientError> {
let path = bootstrap_cache_path().join(reg_type.file_name());
let data = serde_json::to_string_pretty(&registry)?;
let cache_contents = http_data.to_lines(&data)?;
fs::write(path, cache_contents)?;
Ok(())
}
fn get_dns_urls(&self, ldh: &str) -> Result<Vec<String>, icann_rdap_client::RdapClientError> {
let path = bootstrap_cache_path().join(IanaRegistryType::RdapBootstrapDns.file_name());
let (iana, _http_data) = fetch_file_cache_bootstrap(path, |s| debug!("Reading {s}"))?;
Ok(iana.get_dns_bootstrap_urls(ldh)?)
}
fn get_asn_urls(&self, asn: &str) -> Result<Vec<String>, icann_rdap_client::RdapClientError> {
let path = bootstrap_cache_path().join(IanaRegistryType::RdapBootstrapAsn.file_name());
let (iana, _http_data) = fetch_file_cache_bootstrap(path, |s| debug!("Reading {s}"))?;
Ok(iana.get_asn_bootstrap_urls(asn)?)
}
fn get_ipv4_urls(&self, ipv4: &str) -> Result<Vec<String>, icann_rdap_client::RdapClientError> {
let path = bootstrap_cache_path().join(IanaRegistryType::RdapBootstrapIpv4.file_name());
let (iana, _http_data) = fetch_file_cache_bootstrap(path, |s| debug!("Reading {s}"))?;
Ok(iana.get_ipv4_bootstrap_urls(ipv4)?)
}
fn get_ipv6_urls(&self, ipv6: &str) -> Result<Vec<String>, icann_rdap_client::RdapClientError> {
let path = bootstrap_cache_path().join(IanaRegistryType::RdapBootstrapIpv6.file_name());
let (iana, _http_data) = fetch_file_cache_bootstrap(path, |s| debug!("Reading {s}"))?;
Ok(iana.get_ipv6_bootstrap_urls(ipv6)?)
}
fn get_tag_urls(&self, tag: &str) -> Result<Vec<String>, icann_rdap_client::RdapClientError> {
let path = bootstrap_cache_path().join(IanaRegistryType::RdapObjectTags.file_name());
let (iana, _http_data) = fetch_file_cache_bootstrap(path, |s| debug!("Reading {s}"))?;
Ok(iana.get_tag_bootstrap_urls(tag)?)
}
}
pub fn fetch_file_cache_bootstrap<F>(
path: PathBuf,
callback: F,
) -> Result<(IanaRegistry, HttpData), std::io::Error>
where
F: FnOnce(String),
{
let input = File::open(&path)?;
let buf = BufReader::new(input);
let mut lines = vec![];
for line in buf.lines() {
lines.push(line?);
}
let cache_data = HttpData::from_lines(&lines)?;
callback(path.display().to_string());
let iana: IanaRegistry = serde_json::from_str(&cache_data.1.join(""))?;
Ok((iana, cache_data.0))
}
#[cfg(test)]
#[allow(non_snake_case)]
mod test {
use {
icann_rdap_client::{
iana::{BootstrapStore, PreferredUrl},
rdap::QueryType,
},
icann_rdap_common::{
httpdata::HttpData,
iana::{IanaRegistry, IanaRegistryType},
},
serial_test::serial,
test_dir::{DirBuilder, FileType, TestDir},
};
use crate::dirs::{self, fcbs::FileCacheBootstrapStore};
fn test_dir() -> TestDir {
let test_dir = TestDir::temp()
.create("cache", FileType::Dir)
.create("config", FileType::Dir);
std::env::set_var("XDG_CACHE_HOME", test_dir.path("cache"));
std::env::set_var("XDG_CONFIG_HOME", test_dir.path("config"));
dirs::init().expect("unable to init directories");
test_dir
}
#[test]
#[serial]
fn GIVEN_fcbootstrap_with_dns_WHEN_get_domain_query_url_THEN_correct_url() {
// GIVEN
let _test_dir = test_dir();
let bs = FileCacheBootstrapStore;
let bootstrap = r#"
{
"version": "1.0",
"publication": "2024-01-07T10:11:12Z",
"description": "Some text",
"services": [
[
["net", "com"],
[
"https://registry.example.com/myrdap/"
]
],
[
["org", "mytld"],
[
"https://example.org/"
]
]
]
}
"#;
let iana =
serde_json::from_str::<IanaRegistry>(bootstrap).expect("cannot parse domain bootstrap");
bs.put_bootstrap_registry(
&IanaRegistryType::RdapBootstrapDns,
iana,
HttpData::example().build(),
)
.expect("put iana registry");
// WHEN
let actual = bs
.get_domain_query_urls(&QueryType::domain("example.org").expect("invalid domain name"))
.expect("get bootstrap url")
.preferred_url()
.expect("preferred url");
// THEN
assert_eq!(actual, "https://example.org/")
}
#[test]
#[serial]
fn GIVEN_fcbootstrap_with_autnum_WHEN_get_autnum_query_url_THEN_correct_url() {
// GIVEN
let _test_dir = test_dir();
let bs = FileCacheBootstrapStore;
let bootstrap = r#"
{
"version": "1.0",
"publication": "2024-01-07T10:11:12Z",
"description": "RDAP Bootstrap file for example registries.",
"services": [
[
["64496-64496"],
[
"https://rir3.example.com/myrdap/"
]
],
[
["64497-64510", "65536-65551"],
[
"https://example.org/"
]
],
[
["64512-65534"],
[
"http://example.net/rdaprir2/",
"https://example.net/rdaprir2/"
]
]
]
}
"#;
let iana =
serde_json::from_str::<IanaRegistry>(bootstrap).expect("cannot parse autnum bootstrap");
bs.put_bootstrap_registry(
&IanaRegistryType::RdapBootstrapAsn,
iana,
HttpData::example().build(),
)
.expect("put iana registry");
// WHEN
let actual = bs
.get_autnum_query_urls(&QueryType::autnum("as64512").expect("invalid autnum"))
.expect("get bootstrap url")
.preferred_url()
.expect("preferred url");
// THEN
assert_eq!(actual, "https://example.net/rdaprir2/");
}
#[test]
#[serial]
fn GIVEN_fcbootstrap_with_ipv4_THEN_get_ipv4_query_urls_THEN_correct_url() {
// GIVEN
let _test_dir = test_dir();
let bs = FileCacheBootstrapStore;
let bootstrap = r#"
{
"version": "1.0",
"publication": "2024-01-07T10:11:12Z",
"description": "RDAP Bootstrap file for example registries.",
"services": [
[
["198.51.100.0/24", "192.0.0.0/8"],
[
"https://rir1.example.com/myrdap/"
]
],
[
["203.0.113.0/24", "192.0.2.0/24"],
[
"https://example.org/"
]
],
[
["203.0.113.0/28"],
[
"https://example.net/rdaprir2/",
"http://example.net/rdaprir2/"
]
]
]
}
"#;
let iana =
serde_json::from_str::<IanaRegistry>(bootstrap).expect("cannot parse autnum bootstrap");
bs.put_bootstrap_registry(
&IanaRegistryType::RdapBootstrapIpv4,
iana,
HttpData::example().build(),
)
.expect("put iana registry");
// WHEN
let actual = bs
.get_ipv4_query_urls(&QueryType::ipv4("198.51.100.1").expect("invalid IP address"))
.expect("get bootstrap url")
.preferred_url()
.expect("preferred url");
// THEN
assert_eq!(actual, "https://rir1.example.com/myrdap/");
}
#[test]
#[serial]
fn GIVEN_fcbootstrap_with_ipv6_THEN_get_ipv6_query_urls_THEN_correct_url() {
// GIVEN
let _test_dir = test_dir();
let bs = FileCacheBootstrapStore;
let bootstrap = r#"
{
"version": "1.0",
"publication": "2024-01-07T10:11:12Z",
"description": "RDAP Bootstrap file for example registries.",
"services": [
[
["2001:db8::/34"],
[
"https://rir2.example.com/myrdap/"
]
],
[
["2001:db8:4000::/36", "2001:db8:ffff::/48"],
[
"https://example.org/"
]
],
[
["2001:db8:1000::/36"],
[
"https://example.net/rdaprir2/",
"http://example.net/rdaprir2/"
]
]
]
}
"#;
let iana =
serde_json::from_str::<IanaRegistry>(bootstrap).expect("cannot parse autnum bootstrap");
bs.put_bootstrap_registry(
&IanaRegistryType::RdapBootstrapIpv6,
iana,
HttpData::example().build(),
)
.expect("put iana registry");
// WHEN
let actual = bs
.get_ipv6_query_urls(&QueryType::ipv6("2001:db8::1").expect("invalid IP address"))
.expect("get bootstrap url")
.preferred_url()
.expect("preferred url");
// THEN
assert_eq!(actual, "https://rir2.example.com/myrdap/");
}
#[test]
#[serial]
fn GIVEN_fcbootstrap_with_tag_THEN_get_entity_handle_query_urls_THEN_correct_url() {
// GIVEN
let _test_dir = test_dir();
let bs = FileCacheBootstrapStore;
let bootstrap = r#"
{
"version": "1.0",
"publication": "YYYY-MM-DDTHH:MM:SSZ",
"description": "RDAP bootstrap file for service provider object tags",
"services": [
[
["contact@example.com"],
["YYYY"],
[
"https://example.com/rdap/"
]
],
[
["contact@example.org"],
["ZZ54"],
[
"http://rdap.example.org/"
]
],
[
["contact@example.net"],
["1754"],
[
"https://example.net/rdap/",
"http://example.net/rdap/"
]
]
]
}
"#;
let iana =
serde_json::from_str::<IanaRegistry>(bootstrap).expect("cannot parse autnum bootstrap");
bs.put_bootstrap_registry(
&IanaRegistryType::RdapObjectTags,
iana,
HttpData::example().build(),
)
.expect("put iana registry");
// WHEN
let actual = bs
.get_entity_handle_query_urls(&QueryType::Entity("foo-YYYY".to_string()))
.expect("get bootstrap url")
.preferred_url()
.expect("preferred url");
// THEN
assert_eq!(actual, "https://example.com/rdap/");
}
}

View file

@ -0,0 +1,4 @@
pub mod fcbs;
pub mod project;
pub use project::*;

View file

@ -0,0 +1,57 @@
use std::{
fs::{create_dir_all, remove_dir_all, write},
path::PathBuf,
sync::LazyLock,
};
use directories::ProjectDirs;
pub const QUALIFIER: &str = "org";
pub const ORGANIZATION: &str = "ICANN";
pub const APPLICATION: &str = "rdap";
pub const ENV_FILE_NAME: &str = "rdap.env";
pub const RDAP_CACHE_NAME: &str = "rdap_cache";
pub const BOOTSTRAP_CACHE_NAME: &str = "bootstrap_cache";
pub(crate) static PROJECT_DIRS: LazyLock<ProjectDirs> = LazyLock::new(|| {
ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
.expect("unable to formulate project directories")
});
/// Initializes the directories to be used.
pub fn init() -> Result<(), std::io::Error> {
create_dir_all(PROJECT_DIRS.config_dir())?;
create_dir_all(PROJECT_DIRS.cache_dir())?;
create_dir_all(rdap_cache_path())?;
create_dir_all(bootstrap_cache_path())?;
// create default config file
if !config_path().exists() {
let example_config = include_str!("rdap.env");
write(config_path(), example_config)?;
}
Ok(())
}
/// Reset the directories.
pub fn reset() -> Result<(), std::io::Error> {
remove_dir_all(PROJECT_DIRS.config_dir())?;
remove_dir_all(PROJECT_DIRS.cache_dir())?;
init()
}
/// Returns a [PathBuf] to the configuration file.
pub fn config_path() -> PathBuf {
PROJECT_DIRS.config_dir().join(ENV_FILE_NAME)
}
/// Returns a [PathBuf] to the cache directory for RDAP responses.
pub fn rdap_cache_path() -> PathBuf {
PROJECT_DIRS.cache_dir().join(RDAP_CACHE_NAME)
}
/// Returns a [PathBuf] to the cache directory for bootstrap files.
pub fn bootstrap_cache_path() -> PathBuf {
PROJECT_DIRS.cache_dir().join(BOOTSTRAP_CACHE_NAME)
}

View file

@ -0,0 +1,40 @@
# This file controls the environment variables for the RDAP CLI.
# The file format is that of a shell script setting variables.
# Use --help to determine the active values.
# Sets the logging level. Valid values are off, error, warn, info, debug, and trace.
#RDAP_LOG=info
# Determines if output is sent to a pager. Valid values are embedded, none, and auto.
#RDAP_PAGING=none
# Determines the output format of the output. Valid values are markdown, rendered-markdown, pretty-json, json, json-extra, and auto.
#RDAP_OUTPUT=auto
# Sets a base URL from a name in the RDAP bootstrap registry.
#RDAP_BASE=
# Sets a base URL explicitly.
#RDAP_BASE_URL
# Where to lookup TLDs
#RDAP_TLD_LOOKUP=iana
# Which base URL to use if no IP address or autnum bootstrap can be found.
#RDAP_INR_BACKUP_BOOTSTRAP=arin
# Do not use cache.
#RDAP_NO_CACHE=true
# The maximum age of an item in the cache.
#RDAP_MAX_CACHE_AGE=86400
# Allow HTTP connections
#RDAP_ALLOW_HTTP=true
# Allow invalid host names in HTTPS.
#RDAP_ALLOW_INVALID_HOST_NAMES=true
# Allow invalid certificates in HTTPS.
#RDAP_ALLOW_INVALID_CERTIFICATES=true

View file

@ -0,0 +1,2 @@
pub mod dirs;
pub mod rt;

View file

@ -0,0 +1,463 @@
//! Function to execute tests.
use std::{
net::{Ipv4Addr, Ipv6Addr},
str::FromStr,
};
use {
hickory_client::{
client::{AsyncClient, ClientConnection, ClientHandle},
rr::{DNSClass, Name, RecordType},
udp::UdpClientConnection,
},
icann_rdap_client::{
http::{create_client, create_client_with_addr, ClientConfig},
iana::{qtype_to_bootstrap_url, BootstrapStore},
rdap::{rdap_url_request, QueryType},
RdapClientError,
},
icann_rdap_common::response::{get_related_links, ExtensionId},
reqwest::{header::HeaderValue, Url},
thiserror::Error,
tracing::{debug, info},
url::ParseError,
};
use crate::rt::results::{RunFeature, TestRun};
use super::results::{DnsData, TestResults};
#[derive(Default)]
pub struct TestOptions {
pub skip_v4: bool,
pub skip_v6: bool,
pub skip_origin: bool,
pub origin_value: String,
pub chase_referral: bool,
pub expect_extensions: Vec<String>,
pub expect_groups: Vec<ExtensionGroup>,
pub allow_unregistered_extensions: bool,
pub one_addr: bool,
pub dns_resolver: Option<String>,
}
#[derive(Clone)]
pub enum ExtensionGroup {
Gtld,
Nro,
NroAsn,
}
#[derive(Debug, Error)]
pub enum TestExecutionError {
#[error(transparent)]
RdapClient(#[from] RdapClientError),
#[error(transparent)]
UrlParseError(#[from] ParseError),
#[error(transparent)]
AddrParseError(#[from] std::net::AddrParseError),
#[error("No host to resolve")]
NoHostToResolve,
#[error("No rdata")]
NoRdata,
#[error("Bad rdata")]
BadRdata,
#[error(transparent)]
Client(#[from] reqwest::Error),
#[error(transparent)]
InvalidHeader(#[from] reqwest::header::InvalidHeaderValue),
#[error("Unsupporte Query Type")]
UnsupportedQueryType,
#[error("No referral to chase")]
NoReferralToChase,
#[error("Unregistered extension")]
UnregisteredExtension,
}
pub async fn execute_tests<'a, BS: BootstrapStore>(
bs: &BS,
value: &QueryType,
options: &TestOptions,
client_config: &ClientConfig,
) -> Result<TestResults, TestExecutionError> {
let bs_client = create_client(client_config)?;
// normalize extensions
let extensions = normalize_extension_ids(options)?;
let options = &TestOptions {
expect_extensions: extensions,
expect_groups: options.expect_groups.clone(),
origin_value: options.origin_value.clone(),
dns_resolver: options.dns_resolver.clone(),
..*options
};
// get the query url
let mut query_url = match value {
QueryType::Help => return Err(TestExecutionError::UnsupportedQueryType),
QueryType::Url(url) => url.to_owned(),
_ => {
let base_url = qtype_to_bootstrap_url(&bs_client, bs, value, |reg| {
info!("Fetching IANA registry {} for value {value}", reg.url())
})
.await?;
value.query_url(&base_url)?
}
};
// if the URL to test is a referral
if options.chase_referral {
let client = create_client(client_config)?;
info!("Fetching referral from {query_url}");
let response_data = rdap_url_request(&query_url, &client).await?;
query_url = get_related_links(&response_data.rdap)
.first()
.ok_or(TestExecutionError::NoReferralToChase)?
.to_string();
info!("Referral is {query_url}");
}
let parsed_url = Url::parse(&query_url)?;
let port = parsed_url.port().unwrap_or_else(|| {
if parsed_url.scheme().eq("https") {
443
} else {
80
}
});
let host = parsed_url
.host_str()
.ok_or(TestExecutionError::NoHostToResolve)?;
info!("Testing {query_url}");
let dns_data = get_dns_records(host, options).await?;
let mut test_results = TestResults::new(query_url.clone(), dns_data.clone());
let mut more_runs = true;
for v4 in dns_data.v4_addrs {
// test run without origin
let mut test_run = TestRun::new_v4(vec![], v4, port);
if !options.skip_v4 && more_runs {
let client = create_client_with_addr(client_config, host, test_run.socket_addr)?;
info!("Sending request to {}", test_run.socket_addr);
let rdap_response = rdap_url_request(&query_url, &client).await;
test_run = test_run.end(rdap_response, options);
}
test_results.add_test_run(test_run);
// test run with origin
let mut test_run = TestRun::new_v4(vec![RunFeature::OriginHeader], v4, port);
if !options.skip_v4 && !options.skip_origin && more_runs {
let client_config = ClientConfig::from_config(client_config)
.origin(HeaderValue::from_str(&options.origin_value)?)
.build();
let client = create_client_with_addr(&client_config, host, test_run.socket_addr)?;
info!("Sending request to {}", test_run.socket_addr);
let rdap_response = rdap_url_request(&query_url, &client).await;
test_run = test_run.end(rdap_response, options);
}
test_results.add_test_run(test_run);
if options.one_addr {
more_runs = false;
}
}
let mut more_runs = true;
for v6 in dns_data.v6_addrs {
// test run without origin
let mut test_run = TestRun::new_v6(vec![], v6, port);
if !options.skip_v6 && more_runs {
let client = create_client_with_addr(client_config, host, test_run.socket_addr)?;
info!("Sending request to {}", test_run.socket_addr);
let rdap_response = rdap_url_request(&query_url, &client).await;
test_run = test_run.end(rdap_response, options);
}
test_results.add_test_run(test_run);
// test run with origin
let mut test_run = TestRun::new_v6(vec![RunFeature::OriginHeader], v6, port);
if !options.skip_v6 && !options.skip_origin && more_runs {
let client_config = ClientConfig::from_config(client_config)
.origin(HeaderValue::from_str(&options.origin_value)?)
.build();
let client = create_client_with_addr(&client_config, host, test_run.socket_addr)?;
info!("Sending request to {}", test_run.socket_addr);
let rdap_response = rdap_url_request(&query_url, &client).await;
test_run = test_run.end(rdap_response, options);
}
test_results.add_test_run(test_run);
if options.one_addr {
more_runs = false;
}
}
test_results.end(options);
info!("Testing complete.");
Ok(test_results)
}
async fn get_dns_records(host: &str, options: &TestOptions) -> Result<DnsData, TestExecutionError> {
// short circuit dns if these are ip addresses
if let Ok(ip4) = Ipv4Addr::from_str(host) {
return Ok(DnsData {
v4_cname: None,
v6_cname: None,
v4_addrs: vec![ip4],
v6_addrs: vec![],
});
} else if let Ok(ip6) = Ipv6Addr::from_str(host.trim_start_matches('[').trim_end_matches(']')) {
return Ok(DnsData {
v4_cname: None,
v6_cname: None,
v4_addrs: vec![],
v6_addrs: vec![ip6],
});
}
let def_dns_resolver = "8.8.8.8:53".to_string();
let dns_resolver = options.dns_resolver.as_ref().unwrap_or(&def_dns_resolver);
let conn = UdpClientConnection::new(dns_resolver.parse()?)
.unwrap()
.new_stream(None);
let (mut client, bg) = AsyncClient::connect(conn).await.unwrap();
// make sure to run the background task
tokio::spawn(bg);
let mut dns_data = DnsData::default();
// Create a query future
let query = client.query(Name::from_str(host).unwrap(), DNSClass::IN, RecordType::A);
// wait for its response
let response = query.await.unwrap();
for answer in response.answers() {
match answer.record_type() {
RecordType::CNAME => {
let cname = answer
.data()
.ok_or(TestExecutionError::NoRdata)?
.clone()
.into_cname()
.map_err(|_e| TestExecutionError::BadRdata)?
.0
.to_string();
debug!("Found cname {cname}");
dns_data.v4_cname = Some(cname);
}
RecordType::A => {
let addr = answer
.data()
.ok_or(TestExecutionError::NoRdata)?
.clone()
.into_a()
.map_err(|_e| TestExecutionError::BadRdata)?
.0;
debug!("Found IPv4 {addr}");
dns_data.v4_addrs.push(addr);
}
_ => {
// do nothing
}
};
}
// Create a query future
let query = client.query(
Name::from_str(host).unwrap(),
DNSClass::IN,
RecordType::AAAA,
);
// wait for its response
let response = query.await.unwrap();
for answer in response.answers() {
match answer.record_type() {
RecordType::CNAME => {
let cname = answer
.data()
.ok_or(TestExecutionError::NoRdata)?
.clone()
.into_cname()
.map_err(|_e| TestExecutionError::BadRdata)?
.0
.to_string();
debug!("Found cname {cname}");
dns_data.v6_cname = Some(cname);
}
RecordType::AAAA => {
let addr = answer
.data()
.ok_or(TestExecutionError::NoRdata)?
.clone()
.into_aaaa()
.map_err(|_e| TestExecutionError::BadRdata)?
.0;
debug!("Found IPv6 {addr}");
dns_data.v6_addrs.push(addr);
}
_ => {
// do nothing
}
};
}
Ok(dns_data)
}
fn normalize_extension_ids(options: &TestOptions) -> Result<Vec<String>, TestExecutionError> {
let mut retval = options.expect_extensions.clone();
// check for unregistered extensions
if !options.allow_unregistered_extensions {
for ext in &retval {
if ExtensionId::from_str(ext).is_err() {
return Err(TestExecutionError::UnregisteredExtension);
}
}
}
// put the groups in
for group in &options.expect_groups {
match group {
ExtensionGroup::Gtld => {
retval.push(format!(
"{}|{}",
ExtensionId::IcannRdapResponseProfile0,
ExtensionId::IcannRdapResponseProfile1
));
retval.push(format!(
"{}|{}",
ExtensionId::IcannRdapTechnicalImplementationGuide0,
ExtensionId::IcannRdapTechnicalImplementationGuide1
));
}
ExtensionGroup::Nro => {
retval.push(ExtensionId::NroRdapProfile0.to_string());
retval.push(ExtensionId::Cidr0.to_string());
}
ExtensionGroup::NroAsn => {
retval.push(ExtensionId::NroRdapProfile0.to_string());
retval.push(format!(
"{}|{}",
ExtensionId::NroRdapProfileAsnFlat0,
ExtensionId::NroRdapProfileAsnHierarchical0
));
}
}
}
Ok(retval)
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use icann_rdap_common::response::ExtensionId;
use crate::rt::exec::{ExtensionGroup, TestOptions};
use super::normalize_extension_ids;
#[test]
fn GIVEN_gtld_WHEN_normalize_extensions_THEN_list_contains_gtld_ids() {
// GIVEN
let given = vec![ExtensionGroup::Gtld];
// WHEN
let options = TestOptions {
expect_groups: given,
..Default::default()
};
let actual = normalize_extension_ids(&options).unwrap();
// THEN
let expected1 = format!(
"{}|{}",
ExtensionId::IcannRdapResponseProfile0,
ExtensionId::IcannRdapResponseProfile1
);
assert!(actual.contains(&expected1));
let expected2 = format!(
"{}|{}",
ExtensionId::IcannRdapTechnicalImplementationGuide0,
ExtensionId::IcannRdapTechnicalImplementationGuide1
);
assert!(actual.contains(&expected2));
}
#[test]
fn GIVEN_nro_and_foo_WHEN_normalize_extensions_THEN_list_contains_nro_ids_and_foo() {
// GIVEN
let groups = vec![ExtensionGroup::Nro];
let exts = vec!["foo1".to_string()];
// WHEN
let options = TestOptions {
allow_unregistered_extensions: true,
expect_extensions: exts,
expect_groups: groups,
..Default::default()
};
let actual = normalize_extension_ids(&options).unwrap();
dbg!(&actual);
// THEN
assert!(actual.contains(&ExtensionId::NroRdapProfile0.to_string()));
assert!(actual.contains(&ExtensionId::Cidr0.to_string()));
assert!(actual.contains(&"foo1".to_string()));
}
#[test]
fn GIVEN_nro_and_foo_WHEN_unreg_disallowed_THEN_err() {
// GIVEN
let groups = vec![ExtensionGroup::Nro];
let exts = vec!["foo1".to_string()];
// WHEN
let options = TestOptions {
expect_extensions: exts,
expect_groups: groups,
..Default::default()
};
let actual = normalize_extension_ids(&options);
// THEN
assert!(actual.is_err())
}
#[test]
fn GIVEN_unregistered_ext_WHEN_normalize_extensions_THEN_error() {
// GIVEN
let given = vec!["foo".to_string()];
// WHEN
let options = TestOptions {
expect_extensions: given,
..Default::default()
};
let actual = normalize_extension_ids(&options);
// THEN
assert!(actual.is_err());
}
#[test]
fn GIVEN_unregistered_ext_WHEN_allowed_THEN_no_error() {
// GIVEN
let given = vec!["foo".to_string()];
// WHEN
let options = TestOptions {
expect_extensions: given,
allow_unregistered_extensions: true,
..Default::default()
};
let actual = normalize_extension_ids(&options);
// THEN
assert!(actual.is_ok());
}
}

View file

@ -0,0 +1,2 @@
pub mod exec;
pub mod results;

View file

@ -0,0 +1,487 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
/// Contains the results of test execution.
use chrono::{DateTime, Utc};
use {
icann_rdap_client::{
md::{string::StringUtil, table::MultiPartTable, MdOptions},
rdap::ResponseData,
RdapClientError,
},
icann_rdap_common::{
check::{traverse_checks, Check, CheckClass, CheckItem, CheckParams, Checks, GetChecks},
response::{ExtensionId, RdapResponse},
},
reqwest::StatusCode,
serde::Serialize,
strum_macros::Display,
};
use super::exec::TestOptions;
#[derive(Debug, Serialize)]
pub struct TestResults {
pub query_url: String,
pub dns_data: DnsData,
pub start_time: DateTime<Utc>,
pub end_time: Option<DateTime<Utc>>,
pub service_checks: Vec<CheckItem>,
pub test_runs: Vec<TestRun>,
}
impl TestResults {
pub fn new(query_url: String, dns_data: DnsData) -> Self {
Self {
query_url,
dns_data,
start_time: Utc::now(),
end_time: None,
service_checks: vec![],
test_runs: vec![],
}
}
pub fn end(&mut self, options: &TestOptions) {
self.end_time = Some(Utc::now());
//service checks
if self.dns_data.v4_cname.is_some() && self.dns_data.v4_addrs.is_empty() {
self.service_checks
.push(Check::CnameWithoutARecords.check_item());
}
if self.dns_data.v6_cname.is_some() && self.dns_data.v6_addrs.is_empty() {
self.service_checks
.push(Check::CnameWithoutAAAARecords.check_item());
}
if self.dns_data.v4_addrs.is_empty() {
self.service_checks.push(Check::NoARecords.check_item());
}
if self.dns_data.v6_addrs.is_empty() {
self.service_checks.push(Check::NoAAAARecords.check_item());
// see if required by ICANN
let tig0 = ExtensionId::IcannRdapTechnicalImplementationGuide0.to_string();
let tig1 = ExtensionId::IcannRdapTechnicalImplementationGuide1.to_string();
let both_tigs = format!("{tig0}|{tig1}");
if options.expect_extensions.contains(&tig0)
|| options.expect_extensions.contains(&tig1)
|| options.expect_extensions.contains(&both_tigs)
{
self.service_checks
.push(Check::Ipv6SupportRequiredByIcann.check_item())
}
}
}
pub fn add_test_run(&mut self, test_run: TestRun) {
self.test_runs.push(test_run);
}
pub fn to_md(&self, options: &MdOptions, check_classes: &[CheckClass]) -> String {
let mut md = String::new();
// h1
md.push_str(&format!(
"\n{}\n",
self.query_url.to_owned().to_header(1, options)
));
// table
let mut table = MultiPartTable::new();
// test results summary
table = table.multi_raw(vec![
"Start Time".to_inline(options),
"End Time".to_inline(options),
"Duration".to_inline(options),
"Tested".to_inline(options),
]);
let (end_time_s, duration_s) = if let Some(end_time) = self.end_time {
(
format_date_time(end_time),
format!("{} s", (end_time - self.start_time).num_seconds()),
)
} else {
("FATAL".to_em(options), "N/A".to_string())
};
let tested = self
.test_runs
.iter()
.filter(|r| matches!(r.outcome, RunOutcome::Tested))
.count();
table = table.multi_raw(vec![
format_date_time(self.start_time),
end_time_s,
duration_s,
format!("{tested} of {}", self.test_runs.len()),
]);
// dns data
table = table.multi_raw(vec![
"DNS Query".to_inline(options),
"DNS Answer".to_inline(options),
]);
let v4_cname = if let Some(ref cname) = self.dns_data.v4_cname {
cname.to_owned()
} else {
format!("{} A records", self.dns_data.v4_addrs.len())
};
table = table.multi_raw(vec!["A (v4)".to_string(), v4_cname]);
let v6_cname = if let Some(ref cname) = self.dns_data.v6_cname {
cname.to_owned()
} else {
format!("{} AAAA records", self.dns_data.v6_addrs.len())
};
table = table.multi_raw(vec!["AAAA (v6)".to_string(), v6_cname]);
// summary of each run
table = table.multi_raw(vec![
"Address".to_inline(options),
"Attributes".to_inline(options),
"Duration".to_inline(options),
"Outcome".to_inline(options),
]);
for test_run in &self.test_runs {
table = test_run.add_summary(table, options);
}
md.push_str(&table.to_md_table(options));
md.push('\n');
// checks that are about the service and not a particular test run
if !self.service_checks.is_empty() {
md.push_str(&"Service Checks".to_string().to_header(1, options));
let mut table = MultiPartTable::new();
table = table.multi_raw(vec!["Message".to_inline(options)]);
for c in &self.service_checks {
let message = check_item_md(c, options);
table = table.multi_raw(vec![message]);
}
md.push_str(&table.to_md_table(options));
md.push('\n');
}
// each run in detail
for run in &self.test_runs {
md.push_str(&run.to_md(options, check_classes));
}
md
}
}
#[derive(Debug, Serialize, Clone, Default)]
pub struct DnsData {
pub v4_cname: Option<String>,
pub v6_cname: Option<String>,
pub v4_addrs: Vec<Ipv4Addr>,
pub v6_addrs: Vec<Ipv6Addr>,
}
#[derive(Debug, Serialize, Display)]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum RunOutcome {
Tested,
NetworkError,
HttpProtocolError,
HttpConnectError,
HttpRedirectResponse,
HttpTimeoutError,
HttpNon200Error,
HttpTooManyRequestsError,
HttpNotFoundError,
HttpBadRequestError,
HttpUnauthorizedError,
HttpForbiddenError,
JsonError,
RdapDataError,
InternalError,
Skipped,
}
#[derive(Debug, Serialize, Display)]
#[strum(serialize_all = "snake_case")]
pub enum RunFeature {
OriginHeader,
}
impl RunOutcome {
pub fn to_md(&self, options: &MdOptions) -> String {
match self {
Self::Tested => self.to_bold(options),
Self::Skipped => self.to_string(),
_ => self.to_em(options),
}
}
}
#[derive(Debug, Serialize)]
pub struct TestRun {
pub features: Vec<RunFeature>,
pub socket_addr: SocketAddr,
pub start_time: DateTime<Utc>,
pub end_time: Option<DateTime<Utc>>,
pub response_data: Option<ResponseData>,
pub outcome: RunOutcome,
pub checks: Option<Checks>,
}
impl TestRun {
fn new(features: Vec<RunFeature>, socket_addr: SocketAddr) -> Self {
Self {
features,
start_time: Utc::now(),
socket_addr,
end_time: None,
response_data: None,
outcome: RunOutcome::Skipped,
checks: None,
}
}
pub fn new_v4(features: Vec<RunFeature>, ipv4: Ipv4Addr, port: u16) -> Self {
Self::new(features, SocketAddr::new(IpAddr::V4(ipv4), port))
}
pub fn new_v6(features: Vec<RunFeature>, ipv6: Ipv6Addr, port: u16) -> Self {
Self::new(features, SocketAddr::new(IpAddr::V6(ipv6), port))
}
pub fn end(
mut self,
rdap_response: Result<ResponseData, RdapClientError>,
options: &TestOptions,
) -> Self {
if let Ok(response_data) = rdap_response {
self.end_time = Some(Utc::now());
self.outcome = RunOutcome::Tested;
self.checks = Some(do_checks(&response_data, options));
self.response_data = Some(response_data);
} else {
self.outcome = match rdap_response.err().unwrap() {
RdapClientError::InvalidQueryValue
| RdapClientError::AmbiquousQueryType
| RdapClientError::Poison
| RdapClientError::DomainNameError(_)
| RdapClientError::BootstrapUnavailable
| RdapClientError::BootstrapError(_)
| RdapClientError::IanaResponse(_) => RunOutcome::InternalError,
RdapClientError::Response(_) => RunOutcome::RdapDataError,
RdapClientError::Json(_) => RunOutcome::JsonError,
RdapClientError::ParsingError(e) => {
let status_code = e.http_data.status_code();
if status_code > 299 && status_code < 400 {
RunOutcome::HttpRedirectResponse
} else {
RunOutcome::JsonError
}
}
RdapClientError::IoError(_) => RunOutcome::NetworkError,
RdapClientError::Client(e) => {
if e.is_redirect() {
RunOutcome::HttpRedirectResponse
} else if e.is_connect() {
RunOutcome::HttpConnectError
} else if e.is_timeout() {
RunOutcome::HttpTimeoutError
} else if e.is_status() {
match e.status().unwrap() {
StatusCode::TOO_MANY_REQUESTS => RunOutcome::HttpTooManyRequestsError,
StatusCode::NOT_FOUND => RunOutcome::HttpNotFoundError,
StatusCode::BAD_REQUEST => RunOutcome::HttpBadRequestError,
StatusCode::UNAUTHORIZED => RunOutcome::HttpUnauthorizedError,
StatusCode::FORBIDDEN => RunOutcome::HttpForbiddenError,
_ => RunOutcome::HttpNon200Error,
}
} else {
RunOutcome::HttpProtocolError
}
}
};
self.end_time = Some(Utc::now());
};
self
}
fn add_summary(&self, mut table: MultiPartTable, options: &MdOptions) -> MultiPartTable {
let duration_s = if let Some(end_time) = self.end_time {
format!("{} ms", (end_time - self.start_time).num_milliseconds())
} else {
"n/a".to_string()
};
table = table.multi_raw(vec![
self.socket_addr.to_string(),
self.attribute_set(),
duration_s,
self.outcome.to_md(options),
]);
table
}
fn to_md(&self, options: &MdOptions, check_classes: &[CheckClass]) -> String {
let mut md = String::new();
// h1
let header_value = format!("{} - {}", self.socket_addr, self.attribute_set());
md.push_str(&format!("\n{}\n", header_value.to_header(1, options)));
// if outcome is tested
if matches!(self.outcome, RunOutcome::Tested) {
// get check items according to class
let mut check_v: Vec<(String, String)> = vec![];
if let Some(ref checks) = self.checks {
traverse_checks(checks, check_classes, None, &mut |struct_name, item| {
let message = check_item_md(item, options);
check_v.push((struct_name.to_string(), message))
});
};
// table
let mut table = MultiPartTable::new();
if check_v.is_empty() {
table = table.header_ref(&"No issues or errors.");
} else {
table = table.multi_raw(vec![
"RDAP Structure".to_inline(options),
"Message".to_inline(options),
]);
for c in check_v {
table = table.nv_raw(&c.0, c.1);
}
}
md.push_str(&table.to_md_table(options));
} else {
let mut table = MultiPartTable::new();
table = table.multi_raw(vec![self.outcome.to_md(options)]);
md.push_str(&table.to_md_table(options));
}
md
}
fn attribute_set(&self) -> String {
let socket_type = if self.socket_addr.is_ipv4() {
"v4"
} else {
"v6"
};
if !self.features.is_empty() {
format!(
"{socket_type}, {}",
self.features
.iter()
.map(|f| f.to_string())
.collect::<Vec<_>>()
.join(", ")
)
} else {
socket_type.to_string()
}
}
}
fn check_item_md(item: &CheckItem, options: &MdOptions) -> String {
if !matches!(item.check_class, CheckClass::Informational)
&& !matches!(item.check_class, CheckClass::SpecificationNote)
{
item.to_string().to_em(options)
} else {
item.to_string()
}
}
fn format_date_time(date: DateTime<Utc>) -> String {
date.format("%a, %v %X %Z").to_string()
}
fn do_checks(response: &ResponseData, options: &TestOptions) -> Checks {
let check_params = CheckParams {
do_subchecks: true,
root: &response.rdap,
parent_type: response.rdap.get_type(),
allow_unreg_ext: options.allow_unregistered_extensions,
};
let mut checks = response.rdap.get_checks(check_params);
// httpdata checks
checks
.items
.append(&mut response.http_data.get_checks(check_params).items);
// add expected extension checks
for ext in &options.expect_extensions {
if !rdap_has_expected_extension(&response.rdap, ext) {
checks
.items
.push(Check::ExpectedExtensionNotFound.check_item());
}
}
//return
checks
}
fn rdap_has_expected_extension(rdap: &RdapResponse, ext: &str) -> bool {
let count = ext.split('|').filter(|s| rdap.has_extension(s)).count();
count > 0
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use icann_rdap_common::{
prelude::ToResponse,
response::{Domain, Extension},
};
use super::rdap_has_expected_extension;
#[test]
fn GIVEN_expected_extension_WHEN_rdap_has_THEN_true() {
// GIVEN
let domain = Domain::builder()
.extension(Extension::from("foo0"))
.ldh_name("foo.example.com")
.build();
let rdap = domain.to_response();
// WHEN
let actual = rdap_has_expected_extension(&rdap, "foo0");
// THEN
assert!(actual);
}
#[test]
fn GIVEN_expected_extension_WHEN_rdap_does_not_have_THEN_false() {
// GIVEN
let domain = Domain::builder()
.extension(Extension::from("foo0"))
.ldh_name("foo.example.com")
.build();
let rdap = domain.to_response();
// WHEN
let actual = rdap_has_expected_extension(&rdap, "foo1");
// THEN
assert!(!actual);
}
#[test]
fn GIVEN_compound_expected_extension_WHEN_rdap_has_THEN_true() {
// GIVEN
let domain = Domain::builder()
.extension(Extension::from("foo0"))
.ldh_name("foo.example.com")
.build();
let rdap = domain.to_response();
// WHEN
let actual = rdap_has_expected_extension(&rdap, "foo0|foo1");
// THEN
assert!(actual);
}
}

View file

@ -0,0 +1,3 @@
mod rdap_cmd;
mod rdap_test_cmd;
mod test_jig;

View file

@ -0,0 +1,50 @@
#![allow(non_snake_case)]
use {
icann_rdap_client::rdap::RequestResponseOwned,
icann_rdap_common::response::{Domain, Entity, RdapResponse},
icann_rdap_srv::storage::StoreOps,
};
use crate::test_jig::TestJig;
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_domain_with_entity_WHEN_retreived_from_cache_THEN_is_domain() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_domain(
&Domain::builder()
.ldh_name("foo.example")
.entity(Entity::builder().handle("bob").build())
.build(),
)
.await
.expect("add domain in tx");
tx.commit().await.expect("tx commit");
test_jig.cmd.arg("foo.example");
let output = test_jig.cmd.output().expect("executing domain query");
let responses: Vec<RequestResponseOwned> =
serde_json::from_slice(&output.stdout).expect("parsing stdout");
let rdap = &responses.first().expect("response is empty").res_data.rdap;
println!("response type is {rdap}");
// WHEN
let mut test_jig = test_jig.new_cmd();
test_jig.cmd.arg("foo.example");
// THEN
let output = test_jig.cmd.output().expect("executing domain query");
let responses: Vec<RequestResponseOwned> =
serde_json::from_slice(&output.stdout).expect("parsing stdout");
let rdap = &responses.first().expect("response is empty").res_data.rdap;
println!("response type is {rdap}");
assert!(matches!(rdap, RdapResponse::Domain(_)));
let rdap_type = &responses
.first()
.expect("response is empty")
.res_data
.rdap_type;
assert_eq!(rdap_type, "Domain");
}

View file

@ -0,0 +1,23 @@
#![allow(non_snake_case)]
use {icann_rdap_common::response::Domain, icann_rdap_srv::storage::StoreOps};
use crate::test_jig::TestJig;
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_domain_with_check_WHEN_query_THEN_failure() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_domain(&Domain::builder().ldh_name("foo.example").build())
.await
.expect("add domain in tx");
tx.commit().await.expect("tx commit");
// WHEN
test_jig.cmd.arg("--error-on-check").arg("foo.example");
// THEN
let assert = test_jig.cmd.assert();
assert.failure();
}

View file

@ -0,0 +1,5 @@
mod cache;
mod check;
mod queries;
mod source;
mod url;

View file

@ -0,0 +1,214 @@
#![allow(non_snake_case)]
use {
icann_rdap_common::response::{Autnum, Domain, Entity, Nameserver, Network},
icann_rdap_srv::storage::StoreOps,
rstest::rstest,
};
use crate::test_jig::TestJig;
#[rstest]
#[case("foo.example", "foo.example")]
#[case("foo.example", "foo.example.")]
#[case("foo.example", "FOO.EXAMPLE")]
#[case("foó.example", "foó.example")] // unicode
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_domain_WHEN_query_THEN_success(#[case] db_domain: &str, #[case] q_domain: &str) {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_domain(&Domain::builder().ldh_name(db_domain).build())
.await
.expect("add domain in tx");
tx.commit().await.expect("tx commit");
// WHEN
test_jig.cmd.arg(q_domain);
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_tld_WHEN_query_THEN_success() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_domain(&Domain::builder().ldh_name("example").build())
.await
.expect("add domain in tx");
tx.commit().await.expect("tx commit");
// WHEN
// without "--tld-lookup=none" then this attempts to query IANA instead of the test server
test_jig.cmd.arg("--tld-lookup=none").arg(".example");
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_entity_WHEN_query_THEN_success() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_entity(&Entity::builder().handle("foo").build())
.await
.expect("add entity in tx");
tx.commit().await.expect("tx commit");
// WHEN
test_jig.cmd.arg("foo");
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_nameserver_WHEN_query_THEN_success() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_nameserver(
&Nameserver::builder()
.ldh_name("ns.foo.example")
.build()
.unwrap(),
)
.await
.expect("add nameserver in tx");
tx.commit().await.expect("tx commit");
// WHEN
test_jig.cmd.arg("ns.foo.example");
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_autnum_WHEN_query_THEN_success() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_autnum(&Autnum::builder().autnum_range(700..710).build())
.await
.expect("add autnum in tx");
tx.commit().await.expect("tx commit");
// WHEN
test_jig.cmd.arg("700");
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_network_ip_WHEN_query_THEN_success() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_network(
&Network::builder()
.cidr("10.0.0.0/24")
.build()
.expect("cidr parsing"),
)
.await
.expect("add network in tx");
tx.commit().await.expect("tx commit");
// WHEN
test_jig.cmd.arg("10.0.0.1");
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}
#[rstest]
#[case("10.0.0.0/24", "10.0.0.0/24")]
#[case("10.0.0.0/24", "10.0.0/24")]
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_network_cidr_WHEN_query_THEN_success(#[case] db_cidr: &str, #[case] q_cidr: &str) {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_network(
&Network::builder()
.cidr(db_cidr)
.build()
.expect("cidr parsing"),
)
.await
.expect("add network in tx");
tx.commit().await.expect("tx commit");
// WHEN
test_jig.cmd.arg(q_cidr);
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_url_WHEN_query_THEN_success() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_domain(&Domain::builder().ldh_name("foo.example").build())
.await
.expect("add domain in tx");
tx.commit().await.expect("tx commit");
// WHEN
let url = format!("{}/domain/foo.example", test_jig.rdap_base);
test_jig.cmd.arg(url);
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_idn_WHEN_query_a_label_THEN_success() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_domain(&Domain::builder().ldh_name("xn--caf-dma.example").build())
.await
.expect("add domain in tx");
tx.commit().await.expect("tx commit");
// WHEN
test_jig.cmd.arg("-t").arg("a-label").arg("café.example");
// THEN
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_rdap_with_dn_search().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_domain(&Domain::builder().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();
}

View file

@ -0,0 +1,46 @@
#![allow(non_snake_case)]
use {
icann_rdap_client::rdap::{RequestResponseOwned, SourceType},
icann_rdap_common::response::Network,
icann_rdap_srv::storage::StoreOps,
rstest::rstest,
};
use crate::test_jig::TestJig;
#[rstest]
#[case("10.0.0.0/24", "10.0.0.0/24")]
#[case("10.0.0.0/24", "10.0.0.1")]
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_inr_query_WHEN_query_THEN_source_is_rir(
#[case] db_cidr: &str,
#[case] q_cidr: &str,
) {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_network(
&Network::builder()
.cidr(db_cidr)
.build()
.expect("cidr parsing"),
)
.await
.expect("add network in tx");
tx.commit().await.expect("tx commit");
// WHEN
test_jig.cmd.arg(q_cidr);
// THEN
let output = test_jig.cmd.output().expect("executing inr query");
let responses: Vec<RequestResponseOwned> =
serde_json::from_slice(&output.stdout).expect("parsing stdout");
let source_type = responses
.first()
.expect("respons is empty")
.req_data
.source_type;
assert!(matches!(source_type, SourceType::RegionalInternetRegistry));
}

View file

@ -0,0 +1,54 @@
#![allow(non_snake_case)]
use {icann_rdap_common::response::Network, icann_rdap_srv::storage::StoreOps};
use crate::test_jig::TestJig;
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_url_used_with_base_url_WHEN_query_THEN_success() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_network(
&Network::builder()
.cidr("10.0.0.0/24")
.build()
.expect("cidr parsing"),
)
.await
.expect("add network in tx");
tx.commit().await.expect("tx commit");
// WHEN
let url = format!("{}/ip/10.0.0.1", test_jig.rdap_base);
test_jig.cmd.arg(url);
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_url_used_with_no_base_url_WHEN_query_THEN_success() {
// GIVEN
let mut test_jig = TestJig::new_rdap().await;
test_jig.cmd.env_remove("RDAP_BASE_URL");
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_network(
&Network::builder()
.cidr("10.0.0.0/24")
.build()
.expect("cidr parsing"),
)
.await
.expect("add network in tx");
tx.commit().await.expect("tx commit");
// WHEN
let url = format!("{}/ip/10.0.0.1", test_jig.rdap_base);
test_jig.cmd.arg(url);
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}

View file

@ -0,0 +1 @@
mod url;

View file

@ -0,0 +1,30 @@
#![allow(non_snake_case)]
use {icann_rdap_common::response::Network, icann_rdap_srv::storage::StoreOps};
use crate::test_jig::TestJig;
#[tokio::test(flavor = "multi_thread")]
async fn GIVEN_url_WHEN_test_THEN_success() {
// GIVEN
let mut test_jig = TestJig::new_rdap_test().await;
test_jig.cmd.env_remove("RDAP_BASE_URL");
let mut tx = test_jig.mem.new_tx().await.expect("new transaction");
tx.add_network(
&Network::builder()
.cidr("10.0.0.0/24")
.build()
.expect("cidr parsing"),
)
.await
.expect("add network in tx");
tx.commit().await.expect("tx commit");
// WHEN
let url = format!("{}/ip/10.0.0.1", test_jig.rdap_base);
test_jig.cmd.arg(url);
// THEN
let assert = test_jig.cmd.assert();
assert.success();
}

View file

@ -0,0 +1,109 @@
use {
assert_cmd::Command,
icann_rdap_srv::{
config::ListenConfig,
server::{AppState, Listener},
storage::{
mem::{config::MemConfig, ops::Mem},
CommonConfig,
},
},
std::time::Duration,
test_dir::{DirBuilder, FileType, TestDir},
};
pub enum CommandType {
Rdap,
RdapTest,
}
pub struct TestJig {
pub mem: Mem,
pub cmd: Command,
pub cmd_type: CommandType,
pub rdap_base: String,
// pass ownership to the test so the directories are dropped when the test is done.
test_dir: TestDir,
}
impl TestJig {
pub async fn new_rdap() -> Self {
let common_config = CommonConfig::default();
Self::new_common_config(common_config, CommandType::Rdap).await
}
pub async fn new_rdap_with_dn_search() -> Self {
let common_config = CommonConfig::builder()
.domain_search_by_name_enable(true)
.build();
Self::new_common_config(common_config, CommandType::Rdap).await
}
pub async fn new_rdap_test() -> Self {
let common_config = CommonConfig::default();
Self::new_common_config(common_config, CommandType::RdapTest).await
}
pub async fn new_common_config(common_config: CommonConfig, cmd_type: CommandType) -> Self {
let mem = Mem::new(MemConfig::builder().common_config(common_config).build());
let app_state = AppState {
storage: mem.clone(),
bootstrap: false,
};
let _ = tracing_subscriber::fmt().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");
});
let test_dir = TestDir::temp()
.create("cache", FileType::Dir)
.create("config", FileType::Dir);
let cmd = Command::new("sh"); //throw away
Self {
mem,
cmd,
cmd_type,
rdap_base,
test_dir,
}
.new_cmd()
}
/// Creates a new command from an existing one but resetting necessary environment variables.
///
/// Using the function allows the test jig to stay up but a new command to be executed.
pub fn new_cmd(self) -> Self {
let cmd = match self.cmd_type {
CommandType::Rdap => {
let mut cmd = Command::cargo_bin("rdap").expect("cannot find rdap cmd");
cmd.env_clear()
.timeout(Duration::from_secs(2))
.env("RDAP_BASE_URL", self.rdap_base.clone())
.env("RDAP_PAGING", "none")
.env("RDAP_OUTPUT", "json-extra")
.env("RDAP_LOG", "debug")
.env("RDAP_ALLOW_HTTP", "true")
.env("XDG_CACHE_HOME", self.test_dir.path("cache"))
.env("XDG_CONFIG_HOME", self.test_dir.path("config"));
cmd
}
CommandType::RdapTest => {
let mut cmd = Command::cargo_bin("rdap-test").expect("cannot find rdap-test cmd");
cmd.env_clear()
.timeout(Duration::from_secs(2))
.env("RDAP_TEST_LOG", "debug")
.env("RDAP_TEST_ALLOW_HTTP", "true")
.env("XDG_CACHE_HOME", self.test_dir.path("cache"))
.env("XDG_CONFIG_HOME", self.test_dir.path("config"));
cmd
}
};
Self { cmd, ..self }
}
}

View file

@ -0,0 +1,42 @@
[package]
name = "icann-rdap-client"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = """
An RDAP client library.
"""
[dependencies]
icann-rdap-common = { version = "0.0.22", path = "../icann-rdap-common" }
buildstructor.workspace = true
cidr.workspace = true
chrono.workspace = true
const_format.workspace = true
idna.workspace = true
ipnet.workspace = true
jsonpath-rust.workspace = true
jsonpath_lib.workspace = true
pct-str.workspace = true
regex.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
strum_macros.workspace = true
thiserror.workspace = true
tracing.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio.workspace = true
[dev-dependencies]
# fixture testings
rstest = "0.17.0"
# tokio async runtime
tokio = { version = "1.21", features = [ "full" ] }

105
icann-rdap-client/README.md Normal file
View file

@ -0,0 +1,105 @@
ICANN RDAP Client Library
=========================
This is a client library for the Registration Data Access Protocol (RDAP) written and sponsored
by the Internet Corporation for Assigned Names and Numbers [(ICANN)](https://www.icann.org).
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
------------
Add the library to your Cargo.toml: `cargo add icann-rdap-client`
Also, add the commons library: `cargo add icann-rdap-common`.
Both [icann_rdap_common] and this crate can be compiled for WASM targets.
Usage
-----
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:
```rust,no_run
use icann_rdap_client::prelude::*;
use std::str::FromStr;
use tokio::main;
#[tokio::main]
async fn main() -> Result<(), RdapClientError> {
// create a query
let query = QueryType::from_str("192.168.0.1")?;
// or
let query = QueryType::from_str("icann.org")?;
// create a client (from icann-rdap-common)
let config = ClientConfig::default();
// or let config = ClientConfig::builder().build();
let client = create_client(&config)?;
// ideally, keep store in same context as client
let store = MemoryBootstrapStore::new();
// issue the RDAP query
let response =
rdap_bootstrapped_request(
&query,
&client,
&store,
|reg| eprintln!("fetching {reg:?}")
).await?;
Ok(())
}
```
To specify a base URL:
```rust,no_run
use icann_rdap_client::prelude::*;
use std::str::FromStr;
use tokio::main;
#[tokio::main]
async fn main() -> Result<(), RdapClientError> {
// create a query
let query = QueryType::from_str("192.168.0.1")?;
// or
let query = QueryType::from_str("icann.org")?;
// create a client (from icann-rdap-common)
let config = ClientConfig::builder().build();
// or let config = ClientConfig::default();
let client = create_client(&config)?;
// issue the RDAP query
let base_url = "https://rdap-bootstrap.arin.net/bootstrap";
let response = rdap_request(base_url, &query, &client).await?;
Ok(())
}
```
License
-------
Licensed under either of
* Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT) at your option.
Contribution
------------
Unless you explicitly state otherwise, any contribution, as defined in the Apache-2.0 license,
intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license,
shall be dual licensed pursuant to the Apache License, Version 2.0 or the MIT License referenced
as above, at ICANNs option, without any additional terms or conditions.

View file

@ -0,0 +1,258 @@
use {
super::{GtldParams, ToGtldWhois},
icann_rdap_common::response::{Boolish, Domain, Event, Nameserver, Network, SecureDns},
};
impl ToGtldWhois for Domain {
fn to_gtld_whois(&self, params: &mut GtldParams) -> String {
let mut gtld = String::new();
gtld.push_str("\n\n");
// Domain Name
let domain_name = format_domain_name(self);
gtld.push_str(&domain_name);
// Domain ID
let domain_id = format_domain_id(self.object_common.handle.as_ref());
gtld.push_str(&domain_id);
// Date Time for Registry
let date_info = format_registry_dates(&self.object_common.events);
gtld.push_str(&date_info);
// Common Object Stuff
let domain_info = format_domain_info(
&self.object_common.status.as_ref().map(|v| v.vec().clone()),
&self.object_common.port_43,
);
gtld.push_str(&domain_info);
// Enitities: registrar and abuse/tech/admin/registrant info
let formatted_data = self.object_common.entities.to_gtld_whois(params);
gtld.push_str(&formatted_data);
// nameservers and network
let additional_info =
format_nameservers_and_network(&self.nameservers, &self.network, params);
gtld.push_str(&additional_info);
// secure dns
let dnssec_info = format_dnssec_info(&self.secure_dns);
gtld.push_str(&dnssec_info);
gtld.push_str(
"URL of the ICANN Whois Inaccuracy Complaint Form: https://www.icann.org/wicf/\n",
);
// last update info
format_last_update_info(&self.object_common.events, &mut gtld);
gtld
}
}
fn format_domain_name(domain: &Domain) -> String {
if let Some(unicode_name) = &domain.unicode_name {
format!("Domain Name: {unicode_name}\n")
} else if let Some(ldh_name) = &domain.ldh_name {
format!("Domain Name: {ldh_name}\n")
} else if let Some(handle) = &domain.object_common.handle {
format!("Domain Name: {handle}\n")
} else {
"Domain Name: \n".to_string()
}
}
fn format_domain_id(handle: Option<&String>) -> String {
if let Some(handle) = handle {
format!("Registry Domain ID: {handle}\n")
} else {
"Registry Domain ID: \n".to_string()
}
}
fn format_registry_dates(events: &Option<Vec<Event>>) -> String {
let mut formatted_dates = String::new();
if let Some(events) = events {
for event in events {
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));
}
}
"expiration" => {
if let Some(event_date) = &event.event_date {
formatted_dates
.push_str(&format!("Registry Expiry Date: {}\n", event_date));
}
}
_ => {}
}
}
}
}
formatted_dates
}
fn format_domain_info(status: &Option<Vec<String>>, port_43: &Option<String>) -> String {
let mut info = String::new();
if let Some(status) = status {
for value in status {
info.push_str(&format!("Domain Status: {}\n", *value));
}
}
if let Some(port_43) = port_43 {
if !port_43.is_empty() {
info.push_str(&format!("Registrar Whois Server: {}\n", port_43));
}
}
info
}
fn format_nameservers_and_network(
nameservers: &Option<Vec<Nameserver>>,
network: &Option<Network>,
params: &mut GtldParams,
) -> String {
let mut gtld = String::new();
if let Some(nameservers) = nameservers {
nameservers
.iter()
.for_each(|ns| gtld.push_str(&ns.to_gtld_whois(params)));
}
if let Some(network) = network {
gtld.push_str(&network.to_gtld_whois(params));
}
gtld
}
fn format_dnssec_info(secure_dns: &Option<SecureDns>) -> String {
let mut dnssec_info = String::new();
if let Some(secure_dns) = secure_dns {
if secure_dns
.delegation_signed
.as_ref()
.unwrap_or(&Boolish::from(false))
.into_bool()
{
dnssec_info.push_str("DNSSEC: signedDelegation\n");
if let Some(ds_data) = &secure_dns.ds_data {
for ds in ds_data {
if let (Some(key_tag), Some(algorithm), Some(digest_type), Some(digest)) = (
ds.key_tag.as_ref(),
ds.algorithm.as_ref(),
ds.digest_type.as_ref(),
ds.digest.as_ref(),
) {
dnssec_info.push_str(&format!(
"DNSSEC DS Data: {} {} {} {}\n",
key_tag, algorithm, digest_type, digest
));
}
}
}
}
}
dnssec_info
}
fn format_last_update_info(events: &Option<Vec<Event>>, gtld: &mut String) {
if let Some(events) = events {
for event in events {
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;
}
}
}
}
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use crate::gtld::ToGtldWhois;
use {
super::GtldParams,
icann_rdap_common::{prelude::ToResponse, response::Domain},
};
use {
serde_json::Value,
std::{any::TypeId, error::Error, fs::File, io::Read},
};
fn process_gtld_file(file_path: &str) -> Result<String, Box<dyn Error>> {
let mut file = File::open(file_path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let toplevel_json_response: Value = serde_json::from_str(&contents)?;
let actual = serde_json::from_value::<Domain>(toplevel_json_response);
let gtld_version_of_the_domain = match actual {
Ok(domain) => {
let rdap_response = Domain::builder().ldh_name("").build().to_response();
let mut gtld_params = GtldParams {
root: &rdap_response,
parent_type: TypeId::of::<Domain>(),
label: "".to_string(),
};
domain.to_gtld_whois(&mut gtld_params)
}
Err(e) => {
return Err(Box::new(e));
}
};
Ok(gtld_version_of_the_domain)
}
#[test]
fn test_ms_click_response() {
let expected_output =
std::fs::read_to_string("src/test_files/microsoft.click-expected.gtld").unwrap();
let output = process_gtld_file("src/test_files/microsoft.click.json").unwrap();
assert_eq!(output, expected_output);
}
#[test]
fn test_lemonde_response() {
let expected_output =
std::fs::read_to_string("src/test_files/lemonde.fr-expected.gtld").unwrap();
let output = process_gtld_file("src/test_files/lemonde.fr.json").unwrap();
assert_eq!(output, expected_output);
}
#[test]
fn test_moscow_response() {
let expected_output =
std::fs::read_to_string("src/test_files/home.moscow-expected.gtld").unwrap();
let output = process_gtld_file("src/test_files/home.moscow.json").unwrap();
assert_eq!(output, expected_output);
}
}

View file

@ -0,0 +1,270 @@
use {
super::{GtldParams, RoleInfo, ToGtldWhois},
icann_rdap_common::{
contact::{Contact, PostalAddress},
response::Entity,
},
};
impl ToGtldWhois for Option<Vec<Entity>> {
fn to_gtld_whois(&self, params: &mut GtldParams) -> String {
let mut front_formatted_data = String::new();
let mut formatted_data = String::new();
if let Some(entities) = self {
for entity in entities {
for role in entity.roles() {
match role.as_str() {
"registrar" => {
if let Some(vcard_array) = &entity.vcard_array {
let role_info = extract_role_info(role, vcard_array, params);
// Now use role_info to append to formatted_data
if !role_info.name.is_empty() {
front_formatted_data +=
&format!("{}: {}\n", cfl(role), role_info.name);
}
if !role_info.org.is_empty() {
front_formatted_data +=
&format!("{} Organization: {}\n", cfl(role), role_info.org);
}
if !role_info.adr.is_empty() {
front_formatted_data += &role_info.adr;
}
}
// Special Sauce for Registrar IANA ID and Abuse Contact
if let Some(public_ids) = &entity.public_ids {
for public_id in public_ids {
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()
);
}
}
}
}
}
append_abuse_contact_info(entity, &mut front_formatted_data);
}
"technical" | "administrative" | "registrant" => {
if let Some(vcard_array) = &entity.vcard_array {
let role_info = extract_role_info(role, vcard_array, params);
// Now use role_info to append to formatted_data
if !role_info.name.is_empty() {
formatted_data +=
&format!("{} Name: {}\n", cfl(role), role_info.name);
}
if !role_info.org.is_empty() {
formatted_data +=
&format!("{} Organization: {}\n", cfl(role), role_info.org);
}
if !role_info.adr.is_empty() {
formatted_data += &role_info.adr;
}
if !role_info.email.is_empty() {
formatted_data +=
&format!("{} Email: {}\n", cfl(role), role_info.email);
}
if !role_info.phone.is_empty() {
formatted_data +=
&format!("{} Phone: {}\n", cfl(role), role_info.phone);
}
if !role_info.fax.is_empty() {
formatted_data +=
&format!("{} Fax: {}\n", cfl(role), role_info.fax);
}
}
}
_ => {} // Are there any roles we are missing?
}
}
}
}
front_formatted_data += &formatted_data;
front_formatted_data
}
}
fn format_address_with_label(
params: &mut GtldParams,
address_components: &[serde_json::Value],
) -> String {
// TODO once from_vcard is fixed to handle the way addressing is done, replace this with the normal builder.
let postal_address = PostalAddress::builder()
.street_parts(
address_components
.get(2)
.and_then(|v| v.as_str())
.map_or_else(Vec::new, |s| vec![s.to_string()]),
)
.locality(
address_components
.get(3)
.and_then(|v| v.as_str())
.map_or_else(String::new, String::from),
)
.region_name(
address_components
.get(4)
.and_then(|v| v.as_str())
.map_or_else(String::new, String::from),
)
.country_name(
address_components
.get(6)
.and_then(|v| v.as_str())
.map_or_else(String::new, String::from),
)
.country_code(
address_components
.get(6)
.and_then(|v| v.as_str())
.map_or_else(String::new, String::from),
)
.postal_code(
address_components
.get(5)
.and_then(|v| v.as_str())
.map_or_else(String::new, String::from),
)
.build();
postal_address.to_gtld_whois(params).to_string()
}
fn extract_role_info(
role: &str,
vcard_array: &[serde_json::Value],
params: &mut GtldParams,
) -> RoleInfo {
let contact = match Contact::from_vcard(vcard_array) {
Some(contact) => contact,
None => return RoleInfo::default(),
};
let mut adr = String::new();
let label = match role {
"registrar" => "Registrar",
"technical" => "Technical",
"administrative" => "Admin",
"registrant" => "Registrant",
_ => "",
};
params.label = label.to_string();
let name = contact.full_name.unwrap_or_default();
let org = contact
.organization_names
.and_then(|orgs| orgs.first().cloned())
.unwrap_or_default();
// TODO this is a workout to get the address out of the contact. Replace this when from_vcard is fixed
for vcard in vcard_array.iter() {
if let Some(properties) = vcard.as_array() {
for property in properties {
if let Some(property) = property.as_array() {
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);
}
}
}
}
}
}
let email = contact
.emails
.and_then(|emails| emails.first().map(|email| email.email.clone()))
.unwrap_or_default();
let phone = contact
.phones
.as_ref()
.and_then(|phones| {
phones
.iter()
.find(|phone| {
phone
.features
.as_ref()
.map_or(true, |features| !features.contains(&"fax".to_string()))
})
.map(|phone| phone.phone.clone())
})
.unwrap_or_default();
let fax = contact
.phones
.as_ref()
.and_then(|phones| {
phones
.iter()
.find(|phone| {
phone
.features
.as_ref()
.map_or(false, |features| features.contains(&"fax".to_string()))
})
.map(|phone| phone.phone.clone())
})
.unwrap_or_default();
RoleInfo {
name,
org,
adr,
email,
phone,
fax,
}
}
fn append_abuse_contact_info(entity: &Entity, front_formatted_data: &mut String) {
if let Some(entities) = &entity.object_common.entities {
for entity in entities {
for role in entity.roles() {
if role.as_str() == "abuse" {
if let Some(vcard_array) = &entity.vcard_array {
if let Some(contact) = Contact::from_vcard(vcard_array) {
// Emails
if let Some(emails) = &contact.emails {
for email in emails {
let abuse_contact_email = &email.email;
if !abuse_contact_email.is_empty() {
front_formatted_data.push_str(&format!(
"Registrar Abuse Contact Email: {}\n",
abuse_contact_email
));
}
}
}
// Phones
if let Some(phones) = &contact.phones {
for phone in phones {
let abuse_contact_phone = &phone.phone;
if !abuse_contact_phone.is_empty() {
front_formatted_data.push_str(&format!(
"Registrar Abuse Contact Phone: {}\n",
abuse_contact_phone
));
}
}
}
}
}
}
}
}
}
}
// capitalize first letter
fn cfl(s: &str) -> String {
s.char_indices()
.next()
.map(|(i, c)| c.to_uppercase().collect::<String>() + &s[i + 1..])
.unwrap_or_default()
}

View file

@ -0,0 +1,83 @@
//! Converts RDAP structures to gTLD Whois output.
use {
icann_rdap_common::{contact::PostalAddress, response::RdapResponse},
std::any::TypeId,
};
pub mod domain;
pub mod entity;
pub mod nameserver;
pub mod network;
pub mod types;
#[derive(Clone)]
pub struct GtldParams<'a> {
pub root: &'a RdapResponse,
pub parent_type: TypeId,
pub label: String,
}
impl GtldParams<'_> {
pub fn from_parent(&mut self, parent_type: TypeId) -> Self {
Self {
parent_type,
root: self.root,
label: self.label.clone(),
}
}
pub fn next_level(&self) -> Self {
Self {
label: self.label.clone(),
..*self
}
}
}
pub trait ToGtldWhois {
fn to_gtld_whois(&self, params: &mut GtldParams) -> String;
}
impl ToGtldWhois for RdapResponse {
fn to_gtld_whois(&self, params: &mut GtldParams) -> String {
let mut gtld = String::new();
let variant_gtld = match &self {
Self::Domain(domain) => domain.to_gtld_whois(params),
_ => String::new(),
};
gtld.push_str(&variant_gtld);
gtld
}
}
impl ToGtldWhois for PostalAddress {
fn to_gtld_whois(&self, params: &mut GtldParams) -> String {
let label = &params.label;
let street = self
.street_parts
.as_ref()
.map(|parts| parts.join(" "))
.unwrap_or_default();
let city = self.locality.as_deref().unwrap_or("");
let state = self.region_name.as_deref().unwrap_or("");
let postal_code = self.postal_code.as_deref().unwrap_or("");
let country = self.country_code.as_deref().unwrap_or("");
format!(
"{} Street: {}\n{} City: {}\n{} State/Province: {}\n{} Postal Code: {}\n{} Country: {}\n",
label, street, label, city, label, state, label, postal_code, label, country
)
}
}
#[derive(Default)]
pub struct RoleInfo {
name: String,
org: String,
adr: String,
email: String,
phone: String,
fax: String,
}

View file

@ -0,0 +1,22 @@
use {
super::{GtldParams, ToGtldWhois},
icann_rdap_common::response::Nameserver,
};
impl ToGtldWhois for Nameserver {
fn to_gtld_whois(&self, _params: &mut GtldParams) -> String {
let mut gtld = String::new();
// header
let header_text = if let Some(unicode_name) = &self.unicode_name {
format!("Name Server: {unicode_name}\n")
} else if let Some(ldh_name) = &self.ldh_name {
format!("Name Server: {ldh_name}\n")
} else if let Some(handle) = &self.object_common.handle {
format!("Name Server: {handle}\n")
} else {
"Name Server: \n".to_string()
};
gtld.push_str(&header_text);
gtld
}
}

View file

@ -0,0 +1,30 @@
use {
super::{GtldParams, ToGtldWhois},
icann_rdap_common::response::Network,
std::any::TypeId,
};
impl ToGtldWhois for Network {
fn to_gtld_whois(&self, params: &mut GtldParams) -> String {
let _typeid = TypeId::of::<Self>();
let mut gtld = String::new();
gtld.push_str(&self.common.to_gtld_whois(params));
let header_text = if self.start_address.is_some() && self.end_address.is_some() {
format!(
"IP Network: {}-{}\n",
&self.start_address.as_ref().unwrap(),
&self.end_address.as_ref().unwrap()
)
} else if let Some(start_address) = &self.start_address {
format!("IP Network: {start_address}\n")
} else if let Some(handle) = &self.object_common.handle {
format!("IP Network: {handle}\n")
} else if let Some(name) = &self.name {
format!("IP Network: {name}\n")
} else {
"IP Network:\n".to_string()
};
gtld.push_str(&header_text);
gtld
}
}

View file

@ -0,0 +1,10 @@
use {
super::{GtldParams, ToGtldWhois},
icann_rdap_common::response::Common,
};
impl ToGtldWhois for Common {
fn to_gtld_whois(&self, _params: &mut GtldParams) -> String {
String::new()
}
}

View file

@ -0,0 +1,9 @@
//! The HTTP layer of RDAP.
#[doc(inline)]
pub use reqwest::*;
#[doc(inline)]
pub use wrapped::*;
pub(crate) mod reqwest;
pub(crate) mod wrapped;

View file

@ -0,0 +1,226 @@
//! Creates a Reqwest client.
pub use reqwest::{
header::{self, HeaderValue},
Client as ReqwestClient, Error as ReqwestError,
};
use icann_rdap_common::media_types::{JSON_MEDIA_TYPE, RDAP_MEDIA_TYPE};
#[cfg(not(target_arch = "wasm32"))]
use {icann_rdap_common::VERSION, std::net::SocketAddr, std::time::Duration};
const ACCEPT_HEADER_VALUES: &str = const_format::formatcp!("{RDAP_MEDIA_TYPE}, {JSON_MEDIA_TYPE}");
/// Configures the HTTP client.
pub struct ReqwestClientConfig {
/// This string is appended to the user agent.
///
/// It is provided so
/// library users may identify their programs.
/// This is ignored on wasm32.
pub user_agent_suffix: String,
/// If set to true, connections will be required to use HTTPS.
///
/// This is ignored on wasm32.
pub https_only: bool,
/// If set to true, invalid host names will be accepted.
///
/// This is ignored on wasm32.
pub accept_invalid_host_names: bool,
/// If set to true, invalid certificates will be accepted.
///
/// This is ignored on wasm32.
pub accept_invalid_certificates: bool,
/// If true, HTTP redirects will be followed.
///
/// This is ignored on wasm32.
pub follow_redirects: bool,
/// Specify Host
pub host: Option<HeaderValue>,
/// Specify the value of the origin header.
///
/// Most browsers ignore this by default.
pub origin: Option<HeaderValue>,
/// Query timeout in seconds.
///
/// This corresponds to the total timeout of the request (connection plus reading all the data).
///
/// This is ignored on wasm32.
pub timeout_secs: u64,
}
impl Default for ReqwestClientConfig {
fn default() -> Self {
Self {
user_agent_suffix: "library".to_string(),
https_only: true,
accept_invalid_host_names: false,
accept_invalid_certificates: false,
follow_redirects: true,
host: None,
origin: None,
timeout_secs: 60,
}
}
}
#[buildstructor::buildstructor]
impl ReqwestClientConfig {
#[builder]
#[allow(clippy::too_many_arguments)]
pub fn new(
user_agent_suffix: Option<String>,
https_only: Option<bool>,
accept_invalid_host_names: Option<bool>,
accept_invalid_certificates: Option<bool>,
follow_redirects: Option<bool>,
host: Option<HeaderValue>,
origin: Option<HeaderValue>,
timeout_secs: Option<u64>,
) -> Self {
let default = Self::default();
Self {
user_agent_suffix: user_agent_suffix.unwrap_or(default.user_agent_suffix),
https_only: https_only.unwrap_or(default.https_only),
accept_invalid_host_names: accept_invalid_host_names
.unwrap_or(default.accept_invalid_host_names),
accept_invalid_certificates: accept_invalid_certificates
.unwrap_or(default.accept_invalid_certificates),
follow_redirects: follow_redirects.unwrap_or(default.follow_redirects),
host,
origin,
timeout_secs: timeout_secs.unwrap_or(default.timeout_secs),
}
}
#[builder(entry = "from_config", exit = "build")]
#[allow(clippy::too_many_arguments)]
pub fn new_from_config(
&self,
user_agent_suffix: Option<String>,
https_only: Option<bool>,
accept_invalid_host_names: Option<bool>,
accept_invalid_certificates: Option<bool>,
follow_redirects: Option<bool>,
host: Option<HeaderValue>,
origin: Option<HeaderValue>,
timeout_secs: Option<u64>,
) -> Self {
Self {
user_agent_suffix: user_agent_suffix.unwrap_or(self.user_agent_suffix.clone()),
https_only: https_only.unwrap_or(self.https_only),
accept_invalid_host_names: accept_invalid_host_names
.unwrap_or(self.accept_invalid_host_names),
accept_invalid_certificates: accept_invalid_certificates
.unwrap_or(self.accept_invalid_certificates),
follow_redirects: follow_redirects.unwrap_or(self.follow_redirects),
host: host.map_or(self.host.clone(), Some),
origin: origin.map_or(self.origin.clone(), Some),
timeout_secs: timeout_secs.unwrap_or(self.timeout_secs),
}
}
}
/// Creates an HTTP client using Reqwest. The Reqwest
/// client holds its own connection pools, so in many
/// uses cases creating only one client per process is
/// necessary.
#[cfg(not(target_arch = "wasm32"))]
pub fn create_reqwest_client(config: &ReqwestClientConfig) -> Result<ReqwestClient, ReqwestError> {
let default_headers = default_headers(config);
let mut client = reqwest::Client::builder();
let redirects = if config.follow_redirects {
reqwest::redirect::Policy::default()
} else {
reqwest::redirect::Policy::none()
};
client = client
.timeout(Duration::from_secs(config.timeout_secs))
.user_agent(format!(
"icann_rdap client {VERSION} {}",
config.user_agent_suffix
))
.redirect(redirects)
.https_only(config.https_only)
.danger_accept_invalid_hostnames(config.accept_invalid_host_names)
.danger_accept_invalid_certs(config.accept_invalid_certificates);
let client = client.default_headers(default_headers).build()?;
Ok(client)
}
/// Creates an HTTP client using Reqwest. The Reqwest
/// client holds its own connection pools, so in many
/// uses cases creating only one client per process is
/// necessary.
#[cfg(not(target_arch = "wasm32"))]
pub fn create_reqwest_client_with_addr(
config: &ReqwestClientConfig,
domain: &str,
addr: SocketAddr,
) -> Result<ReqwestClient, ReqwestError> {
let default_headers = default_headers(config);
let mut client = reqwest::Client::builder();
let redirects = if config.follow_redirects {
reqwest::redirect::Policy::default()
} else {
reqwest::redirect::Policy::none()
};
client = client
.timeout(Duration::from_secs(config.timeout_secs))
.user_agent(format!(
"icann_rdap client {VERSION} {}",
config.user_agent_suffix
))
.redirect(redirects)
.https_only(config.https_only)
.danger_accept_invalid_hostnames(config.accept_invalid_host_names)
.danger_accept_invalid_certs(config.accept_invalid_certificates)
.resolve(domain, addr);
let client = client.default_headers(default_headers).build()?;
Ok(client)
}
/// Creates an HTTP client using Reqwest. The Reqwest
/// client holds its own connection pools, so in many
/// uses cases creating only one client per process is
/// necessary.
/// Note that the WASM version does not set redirect policy,
/// https_only, or TLS settings.
#[cfg(target_arch = "wasm32")]
pub fn create_reqwest_client(config: &ReqwestClientConfig) -> Result<ReqwestClient, ReqwestError> {
let default_headers = default_headers(config);
let client = reqwest::Client::builder();
let client = client.default_headers(default_headers).build()?;
Ok(client)
}
fn default_headers(config: &ReqwestClientConfig) -> header::HeaderMap {
let mut default_headers = header::HeaderMap::new();
default_headers.insert(
header::ACCEPT,
HeaderValue::from_static(ACCEPT_HEADER_VALUES),
);
if let Some(host) = &config.host {
default_headers.insert(header::HOST, host.into());
};
if let Some(origin) = &config.origin {
default_headers.insert(header::ORIGIN, origin.into());
}
default_headers
}

View file

@ -0,0 +1,297 @@
//! Wrapped Client.
pub use reqwest::{header::HeaderValue, Client as ReqwestClient, Error as ReqwestError};
use {
icann_rdap_common::httpdata::HttpData,
reqwest::header::{
ACCESS_CONTROL_ALLOW_ORIGIN, CACHE_CONTROL, CONTENT_TYPE, EXPIRES, LOCATION, RETRY_AFTER,
STRICT_TRANSPORT_SECURITY,
},
};
use {
super::{create_reqwest_client, ReqwestClientConfig},
crate::RdapClientError,
};
#[cfg(not(target_arch = "wasm32"))]
use {
super::create_reqwest_client_with_addr, chrono::DateTime, chrono::Utc, reqwest::StatusCode,
std::net::SocketAddr, tracing::debug, tracing::info,
};
/// Used by the request functions.
#[derive(Clone, Copy)]
pub struct RequestOptions {
pub(crate) max_retry_secs: u32,
pub(crate) def_retry_secs: u32,
pub(crate) max_retries: u16,
}
impl Default for RequestOptions {
fn default() -> Self {
Self {
max_retry_secs: 120,
def_retry_secs: 60,
max_retries: 1,
}
}
}
/// Configures the HTTP client.
#[derive(Default)]
pub struct ClientConfig {
/// Config for the Reqwest client.
client_config: ReqwestClientConfig,
/// Request options.
request_options: RequestOptions,
}
#[buildstructor::buildstructor]
impl ClientConfig {
#[builder]
#[allow(clippy::too_many_arguments)]
pub fn new(
user_agent_suffix: Option<String>,
https_only: Option<bool>,
accept_invalid_host_names: Option<bool>,
accept_invalid_certificates: Option<bool>,
follow_redirects: Option<bool>,
host: Option<HeaderValue>,
origin: Option<HeaderValue>,
timeout_secs: Option<u64>,
max_retry_secs: Option<u32>,
def_retry_secs: Option<u32>,
max_retries: Option<u16>,
) -> Self {
let default_cc = ReqwestClientConfig::default();
let default_ro = RequestOptions::default();
Self {
client_config: ReqwestClientConfig {
user_agent_suffix: user_agent_suffix.unwrap_or(default_cc.user_agent_suffix),
https_only: https_only.unwrap_or(default_cc.https_only),
accept_invalid_host_names: accept_invalid_host_names
.unwrap_or(default_cc.accept_invalid_host_names),
accept_invalid_certificates: accept_invalid_certificates
.unwrap_or(default_cc.accept_invalid_certificates),
follow_redirects: follow_redirects.unwrap_or(default_cc.follow_redirects),
host,
origin,
timeout_secs: timeout_secs.unwrap_or(default_cc.timeout_secs),
},
request_options: RequestOptions {
max_retry_secs: max_retry_secs.unwrap_or(default_ro.max_retry_secs),
def_retry_secs: def_retry_secs.unwrap_or(default_ro.def_retry_secs),
max_retries: max_retries.unwrap_or(default_ro.max_retries),
},
}
}
#[builder(entry = "from_config", exit = "build")]
#[allow(clippy::too_many_arguments)]
pub fn new_from_config(
&self,
user_agent_suffix: Option<String>,
https_only: Option<bool>,
accept_invalid_host_names: Option<bool>,
accept_invalid_certificates: Option<bool>,
follow_redirects: Option<bool>,
host: Option<HeaderValue>,
origin: Option<HeaderValue>,
timeout_secs: Option<u64>,
max_retry_secs: Option<u32>,
def_retry_secs: Option<u32>,
max_retries: Option<u16>,
) -> Self {
Self {
client_config: ReqwestClientConfig {
user_agent_suffix: user_agent_suffix
.unwrap_or(self.client_config.user_agent_suffix.clone()),
https_only: https_only.unwrap_or(self.client_config.https_only),
accept_invalid_host_names: accept_invalid_host_names
.unwrap_or(self.client_config.accept_invalid_host_names),
accept_invalid_certificates: accept_invalid_certificates
.unwrap_or(self.client_config.accept_invalid_certificates),
follow_redirects: follow_redirects.unwrap_or(self.client_config.follow_redirects),
host: host.map_or(self.client_config.host.clone(), Some),
origin: origin.map_or(self.client_config.origin.clone(), Some),
timeout_secs: timeout_secs.unwrap_or(self.client_config.timeout_secs),
},
request_options: RequestOptions {
max_retry_secs: max_retry_secs.unwrap_or(self.request_options.max_retry_secs),
def_retry_secs: def_retry_secs.unwrap_or(self.request_options.def_retry_secs),
max_retries: max_retries.unwrap_or(self.request_options.max_retries),
},
}
}
}
/// A wrapper around Reqwest client to give additional features when used with the request functions.
pub struct Client {
/// The reqwest client.
pub(crate) reqwest_client: ReqwestClient,
/// Request options.
pub(crate) request_options: RequestOptions,
}
impl Client {
pub fn new(reqwest_client: ReqwestClient, request_options: RequestOptions) -> Self {
Self {
reqwest_client,
request_options,
}
}
}
/// Creates a wrapped HTTP client. The wrapped
/// client holds its own connection pools, so in many
/// uses cases creating only one client per process is
/// necessary.
pub fn create_client(config: &ClientConfig) -> Result<Client, RdapClientError> {
let client = create_reqwest_client(&config.client_config)?;
Ok(Client::new(client, config.request_options))
}
/// Creates a wrapped HTTP client.
/// This will direct the underlying client to connect to a specific socket.
#[cfg(not(target_arch = "wasm32"))]
pub fn create_client_with_addr(
config: &ClientConfig,
domain: &str,
addr: SocketAddr,
) -> Result<Client, RdapClientError> {
let client = create_reqwest_client_with_addr(&config.client_config, domain, addr)?;
Ok(Client::new(client, config.request_options))
}
pub(crate) struct WrappedResponse {
pub(crate) http_data: HttpData,
pub(crate) text: String,
}
pub(crate) async fn wrapped_request(
request_uri: &str,
client: &Client,
) -> Result<WrappedResponse, ReqwestError> {
// send request and loop for possible retries
#[allow(unused_mut)] //because of wasm32 exclusion below
let mut response = client.reqwest_client.get(request_uri).send().await?;
// this doesn't work on wasm32 because tokio doesn't work on wasm
#[cfg(not(target_arch = "wasm32"))]
{
let mut tries: u16 = 0;
loop {
debug!("HTTP version: {:?}", response.version());
// don't repeat the request
if !matches!(response.status(), StatusCode::TOO_MANY_REQUESTS) {
break;
}
// loop if HTTP 429
let retry_after_header = response
.headers()
.get(RETRY_AFTER)
.map(|value| value.to_str().unwrap().to_string());
let retry_after = if let Some(rt) = retry_after_header {
info!("Server says too many requests and to retry-after '{rt}'.");
rt
} else {
info!("Server says too many requests but does not offer 'retry-after' value.");
client.request_options.def_retry_secs.to_string()
};
let mut wait_time_seconds = if let Ok(date) = DateTime::parse_from_rfc2822(&retry_after)
{
(date.with_timezone(&Utc) - Utc::now()).num_seconds() as u64
} else if let Ok(seconds) = retry_after.parse::<u64>() {
seconds
} else {
info!(
"Unable to parse retry-after header value. Using {}",
client.request_options.def_retry_secs
);
client.request_options.def_retry_secs.into()
};
if wait_time_seconds == 0 {
info!("Given {wait_time_seconds} for retry-after. Does not make sense.");
wait_time_seconds = client.request_options.def_retry_secs as u64;
}
if wait_time_seconds > client.request_options.max_retry_secs as u64 {
info!(
"Server is asking to wait longer than configured max of {}.",
client.request_options.max_retry_secs
);
wait_time_seconds = client.request_options.max_retry_secs as u64;
}
info!("Waiting {wait_time_seconds} seconds to retry.");
tokio::time::sleep(tokio::time::Duration::from_secs(wait_time_seconds + 1)).await;
tries += 1;
if tries > client.request_options.max_retries {
info!("Max query retries reached.");
break;
} else {
// send the query again
response = client.reqwest_client.get(request_uri).send().await?;
}
}
}
// throw an error if not 200 OK
let response = response.error_for_status()?;
// get the response
let content_type = response
.headers()
.get(CONTENT_TYPE)
.map(|value| value.to_str().unwrap().to_string());
let expires = response
.headers()
.get(EXPIRES)
.map(|value| value.to_str().unwrap().to_string());
let cache_control = response
.headers()
.get(CACHE_CONTROL)
.map(|value| value.to_str().unwrap().to_string());
let location = response
.headers()
.get(LOCATION)
.map(|value| value.to_str().unwrap().to_string());
let access_control_allow_origin = response
.headers()
.get(ACCESS_CONTROL_ALLOW_ORIGIN)
.map(|value| value.to_str().unwrap().to_string());
let strict_transport_security = response
.headers()
.get(STRICT_TRANSPORT_SECURITY)
.map(|value| value.to_str().unwrap().to_string());
let retry_after = response
.headers()
.get(RETRY_AFTER)
.map(|value| value.to_str().unwrap().to_string());
let content_length = response.content_length();
let status_code = response.status().as_u16();
let url = response.url().to_owned();
let text = response.text().await?;
let http_data = HttpData::now()
.status_code(status_code)
.and_location(location)
.and_content_length(content_length)
.and_content_type(content_type)
.scheme(url.scheme())
.host(
url.host_str()
.expect("URL has no host. This shouldn't happen.")
.to_owned(),
)
.and_expires(expires)
.and_cache_control(cache_control)
.and_access_control_allow_origin(access_control_allow_origin)
.and_strict_transport_security(strict_transport_security)
.and_retry_after(retry_after)
.request_uri(request_uri)
.build();
Ok(WrappedResponse { http_data, text })
}

View file

@ -0,0 +1,623 @@
//! Does RDAP query bootstrapping.
use std::sync::{Arc, RwLock};
use icann_rdap_common::{
httpdata::HttpData,
iana::{
get_preferred_url, BootstrapRegistry, BootstrapRegistryError, IanaRegistry,
IanaRegistryType,
},
};
use crate::{http::Client, iana::iana_request::iana_request, rdap::QueryType, RdapClientError};
const SECONDS_IN_WEEK: i64 = 604800;
/// Defines a trait for things that store bootstrap registries.
pub trait BootstrapStore: Send + Sync {
/// Called when store is checked to see if it has a valid bootstrap registry.
///
/// This method should return false (i.e. `Ok(false)``) if the registry doesn't
/// exist in the store or if the registry in the store is out-of-date (such as
/// the cache control data indicates it is old).
fn has_bootstrap_registry(&self, reg_type: &IanaRegistryType) -> Result<bool, RdapClientError>;
/// Puts a registry into the bootstrap registry store.
fn put_bootstrap_registry(
&self,
reg_type: &IanaRegistryType,
registry: IanaRegistry,
http_data: HttpData,
) -> Result<(), RdapClientError>;
/// Get the urls for a domain or nameserver (which are domain names) query type.
///
/// The default method should be good enough for most trait implementations.
fn get_domain_query_urls(
&self,
query_type: &QueryType,
) -> Result<Vec<String>, RdapClientError> {
let domain_name = match query_type {
QueryType::Domain(domain) => domain.to_ascii(),
QueryType::Nameserver(ns) => ns.to_ascii(),
_ => panic!("invalid domain query type"),
};
self.get_dns_urls(domain_name)
}
/// Get the urls for an autnum query type.
///
/// The default method should be good enough for most trait implementations.
fn get_autnum_query_urls(
&self,
query_type: &QueryType,
) -> Result<Vec<String>, RdapClientError> {
let QueryType::AsNumber(asn) = query_type else {
panic!("invalid query type")
};
self.get_asn_urls(asn.to_string().as_str())
}
/// Get the urls for an IPv4 query type.
///
/// The default method should be good enough for most trait implementations.
fn get_ipv4_query_urls(&self, query_type: &QueryType) -> Result<Vec<String>, RdapClientError> {
let ip = match query_type {
QueryType::IpV4Addr(addr) => format!("{addr}/32"),
QueryType::IpV4Cidr(cidr) => cidr.to_string(),
_ => panic!("non ip query for ip bootstrap"),
};
self.get_ipv4_urls(&ip)
}
/// Get the urls for an IPv6 query type.
///
/// The default method should be good enough for most trait implementations.
fn get_ipv6_query_urls(&self, query_type: &QueryType) -> Result<Vec<String>, RdapClientError> {
let ip = match query_type {
QueryType::IpV6Addr(addr) => format!("{addr}/128"),
QueryType::IpV6Cidr(cidr) => cidr.to_string(),
_ => panic!("non ip query for ip bootstrap"),
};
self.get_ipv6_urls(&ip)
}
/// Get the urls for an entity handle query type.
///
/// The default method should be good enough for most trait implementations.
fn get_entity_handle_query_urls(
&self,
query_type: &QueryType,
) -> Result<Vec<String>, RdapClientError> {
let QueryType::Entity(handle) = query_type else {
panic!("non entity handle for bootstrap")
};
let handle_split = handle
.rsplit_once('-')
.ok_or(BootstrapRegistryError::InvalidBootstrapInput)?;
self.get_tag_query_urls(handle_split.1)
}
/// Get the urls for an object tag query type.
///
/// The default method should be good enough for most trait implementations.
fn get_tag_query_urls(&self, tag: &str) -> Result<Vec<String>, RdapClientError> {
self.get_tag_urls(tag)
}
/// Get the URLs associated with the IANA RDAP DNS bootstrap.
///
/// Implementations should implement the logic to pull the [icann_rdap_common::iana::IanaRegistry]
/// and ultimately call its [icann_rdap_common::iana::IanaRegistry::get_dns_bootstrap_urls] method.
fn get_dns_urls(&self, ldh: &str) -> Result<Vec<String>, RdapClientError>;
/// Get the URLs associated with the IANA RDAP ASN bootstrap.
///
/// Implementations should implement the logic to pull the [icann_rdap_common::iana::IanaRegistry]
/// and ultimately call its [icann_rdap_common::iana::IanaRegistry::get_asn_bootstrap_urls] method.
fn get_asn_urls(&self, asn: &str) -> Result<Vec<String>, RdapClientError>;
/// Get the URLs associated with the IANA RDAP IPv4 bootstrap.
///
/// Implementations should implement the logic to pull the [icann_rdap_common::iana::IanaRegistry]
/// and ultimately call its [icann_rdap_common::iana::IanaRegistry::get_ipv4_bootstrap_urls] method.
fn get_ipv4_urls(&self, ipv4: &str) -> Result<Vec<String>, RdapClientError>;
/// Get the URLs associated with the IANA RDAP IPv6 bootstrap.
///
/// Implementations should implement the logic to pull the [icann_rdap_common::iana::IanaRegistry]
/// and ultimately call its [icann_rdap_common::iana::IanaRegistry::get_ipv6_bootstrap_urls] method.
fn get_ipv6_urls(&self, ipv6: &str) -> Result<Vec<String>, RdapClientError>;
/// Get the URLs associated with the IANA RDAP Object Tags bootstrap.
///
/// Implementations should implement the logic to pull the [icann_rdap_common::iana::IanaRegistry]
/// and ultimately call its [icann_rdap_common::iana::IanaRegistry::get_tag_bootstrap_urls] method.
fn get_tag_urls(&self, tag: &str) -> Result<Vec<String>, RdapClientError>;
}
/// A trait to find the preferred URL from a bootstrap service.
pub trait PreferredUrl {
fn preferred_url(self) -> Result<String, RdapClientError>;
}
impl PreferredUrl for Vec<String> {
fn preferred_url(self) -> Result<String, RdapClientError> {
Ok(get_preferred_url(self)?)
}
}
/// A bootstrap registry store backed by memory.
///
/// This implementation of [BootstrapStore] keeps registries in memory. Every new instance starts with
/// no registries in memory. They are added and maintained over time by calls to [MemoryBootstrapStore::put_bootstrap_registry()] by the
/// machinery of [crate::rdap::request::rdap_bootstrapped_request()] and [crate::iana::bootstrap::qtype_to_bootstrap_url()].
///
/// Ideally, this should be kept in the same scope as [reqwest::Client].
pub struct MemoryBootstrapStore {
ipv4: Arc<RwLock<Option<(IanaRegistry, HttpData)>>>,
ipv6: Arc<RwLock<Option<(IanaRegistry, HttpData)>>>,
autnum: Arc<RwLock<Option<(IanaRegistry, HttpData)>>>,
dns: Arc<RwLock<Option<(IanaRegistry, HttpData)>>>,
tag: Arc<RwLock<Option<(IanaRegistry, HttpData)>>>,
}
unsafe impl Send for MemoryBootstrapStore {}
unsafe impl Sync for MemoryBootstrapStore {}
impl Default for MemoryBootstrapStore {
fn default() -> Self {
Self::new()
}
}
impl MemoryBootstrapStore {
pub fn new() -> Self {
Self {
ipv4: <_>::default(),
ipv6: <_>::default(),
autnum: <_>::default(),
dns: <_>::default(),
tag: <_>::default(),
}
}
}
impl BootstrapStore for MemoryBootstrapStore {
fn has_bootstrap_registry(&self, reg_type: &IanaRegistryType) -> Result<bool, RdapClientError> {
Ok(match reg_type {
IanaRegistryType::RdapBootstrapDns => self.dns.read()?.registry_has_not_expired(),
IanaRegistryType::RdapBootstrapAsn => self.autnum.read()?.registry_has_not_expired(),
IanaRegistryType::RdapBootstrapIpv4 => self.ipv4.read()?.registry_has_not_expired(),
IanaRegistryType::RdapBootstrapIpv6 => self.ipv6.read()?.registry_has_not_expired(),
IanaRegistryType::RdapObjectTags => self.tag.read()?.registry_has_not_expired(),
})
}
fn put_bootstrap_registry(
&self,
reg_type: &IanaRegistryType,
registry: IanaRegistry,
http_data: HttpData,
) -> Result<(), RdapClientError> {
match reg_type {
IanaRegistryType::RdapBootstrapDns => {
let mut g = self.dns.write()?;
*g = Some((registry, http_data));
}
IanaRegistryType::RdapBootstrapAsn => {
let mut g = self.autnum.write()?;
*g = Some((registry, http_data));
}
IanaRegistryType::RdapBootstrapIpv4 => {
let mut g = self.ipv4.write()?;
*g = Some((registry, http_data));
}
IanaRegistryType::RdapBootstrapIpv6 => {
let mut g = self.ipv6.write()?;
*g = Some((registry, http_data));
}
IanaRegistryType::RdapObjectTags => {
let mut g = self.tag.write()?;
*g = Some((registry, http_data));
}
};
Ok(())
}
fn get_dns_urls(&self, ldh: &str) -> Result<Vec<String>, RdapClientError> {
if let Some((iana, _http_data)) = self.dns.read()?.as_ref() {
Ok(iana.get_dns_bootstrap_urls(ldh)?)
} else {
Err(RdapClientError::BootstrapUnavailable)
}
}
fn get_asn_urls(&self, asn: &str) -> Result<Vec<String>, RdapClientError> {
if let Some((iana, _http_data)) = self.autnum.read()?.as_ref() {
Ok(iana.get_asn_bootstrap_urls(asn)?)
} else {
Err(RdapClientError::BootstrapUnavailable)
}
}
fn get_ipv4_urls(&self, ipv4: &str) -> Result<Vec<String>, RdapClientError> {
if let Some((iana, _http_data)) = self.ipv4.read()?.as_ref() {
Ok(iana.get_ipv4_bootstrap_urls(ipv4)?)
} else {
Err(RdapClientError::BootstrapUnavailable)
}
}
fn get_ipv6_urls(&self, ipv6: &str) -> Result<Vec<String>, RdapClientError> {
if let Some((iana, _http_data)) = self.ipv6.read()?.as_ref() {
Ok(iana.get_ipv6_bootstrap_urls(ipv6)?)
} else {
Err(RdapClientError::BootstrapUnavailable)
}
}
fn get_tag_urls(&self, tag: &str) -> Result<Vec<String>, RdapClientError> {
if let Some((iana, _http_data)) = self.tag.read()?.as_ref() {
Ok(iana.get_tag_bootstrap_urls(tag)?)
} else {
Err(RdapClientError::BootstrapUnavailable)
}
}
}
/// Trait to determine if a bootstrap registry is past its expiration (i.e. needs to be rechecked).
pub trait RegistryHasNotExpired {
fn registry_has_not_expired(&self) -> bool;
}
impl RegistryHasNotExpired for Option<(IanaRegistry, HttpData)> {
fn registry_has_not_expired(&self) -> bool {
if let Some((_iana, http_data)) = self {
!http_data.is_expired(SECONDS_IN_WEEK)
} else {
false
}
}
}
/// Given a [QueryType], it will get the bootstrap URL.
pub async fn qtype_to_bootstrap_url<F>(
client: &Client,
store: &dyn BootstrapStore,
query_type: &QueryType,
callback: F,
) -> Result<String, RdapClientError>
where
F: FnOnce(&IanaRegistryType),
{
match query_type {
QueryType::IpV4Addr(_) | QueryType::IpV4Cidr(_) => {
fetch_bootstrap(
&IanaRegistryType::RdapBootstrapIpv4,
client,
store,
callback,
)
.await?;
Ok(store.get_ipv4_query_urls(query_type)?.preferred_url()?)
}
QueryType::IpV6Addr(_) | QueryType::IpV6Cidr(_) => {
fetch_bootstrap(
&IanaRegistryType::RdapBootstrapIpv6,
client,
store,
callback,
)
.await?;
Ok(store.get_ipv6_query_urls(query_type)?.preferred_url()?)
}
QueryType::AsNumber(_) => {
fetch_bootstrap(&IanaRegistryType::RdapBootstrapAsn, client, store, callback).await?;
Ok(store.get_autnum_query_urls(query_type)?.preferred_url()?)
}
QueryType::Domain(_) => {
fetch_bootstrap(&IanaRegistryType::RdapBootstrapDns, client, store, callback).await?;
Ok(store.get_domain_query_urls(query_type)?.preferred_url()?)
}
QueryType::Entity(_) => {
fetch_bootstrap(&IanaRegistryType::RdapObjectTags, client, store, callback).await?;
Ok(store
.get_entity_handle_query_urls(query_type)?
.preferred_url()?)
}
QueryType::Nameserver(_) => {
fetch_bootstrap(&IanaRegistryType::RdapBootstrapDns, client, store, callback).await?;
Ok(store.get_domain_query_urls(query_type)?.preferred_url()?)
}
_ => Err(RdapClientError::BootstrapUnavailable),
}
}
/// Fetches a bootstrap registry for a [BootstrapStore].
pub async fn fetch_bootstrap<F>(
reg_type: &IanaRegistryType,
client: &Client,
store: &dyn BootstrapStore,
callback: F,
) -> Result<(), RdapClientError>
where
F: FnOnce(&IanaRegistryType),
{
if !store.has_bootstrap_registry(reg_type)? {
callback(reg_type);
let iana_resp = iana_request(reg_type.clone(), client).await?;
store.put_bootstrap_registry(reg_type, iana_resp.registry, iana_resp.http_data)?;
}
Ok(())
}
#[cfg(test)]
#[allow(non_snake_case)]
mod test {
use icann_rdap_common::{
httpdata::HttpData,
iana::{IanaRegistry, IanaRegistryType},
};
use crate::{iana::bootstrap::PreferredUrl, rdap::QueryType};
use super::{BootstrapStore, MemoryBootstrapStore};
#[test]
fn GIVEN_membootstrap_with_dns_WHEN_get_domain_query_url_THEN_correct_url() {
// GIVEN
let mem = MemoryBootstrapStore::new();
let bootstrap = r#"
{
"version": "1.0",
"publication": "2024-01-07T10:11:12Z",
"description": "Some text",
"services": [
[
["net", "com"],
[
"https://registry.example.com/myrdap/"
]
],
[
["org", "mytld"],
[
"https://example.org/"
]
]
]
}
"#;
let iana =
serde_json::from_str::<IanaRegistry>(bootstrap).expect("cannot parse domain bootstrap");
mem.put_bootstrap_registry(
&IanaRegistryType::RdapBootstrapDns,
iana,
HttpData::example().build(),
)
.expect("put iana registry");
// WHEN
let actual = mem
.get_domain_query_urls(&QueryType::domain("example.org").expect("invalid domain name"))
.expect("get bootstrap url")
.preferred_url()
.expect("preferred url");
// THEN
assert_eq!(actual, "https://example.org/")
}
#[test]
fn GIVEN_membootstrap_with_autnum_WHEN_get_autnum_query_url_THEN_correct_url() {
// GIVEN
let mem = MemoryBootstrapStore::new();
let bootstrap = r#"
{
"version": "1.0",
"publication": "2024-01-07T10:11:12Z",
"description": "RDAP Bootstrap file for example registries.",
"services": [
[
["64496-64496"],
[
"https://rir3.example.com/myrdap/"
]
],
[
["64497-64510", "65536-65551"],
[
"https://example.org/"
]
],
[
["64512-65534"],
[
"http://example.net/rdaprir2/",
"https://example.net/rdaprir2/"
]
]
]
}
"#;
let iana =
serde_json::from_str::<IanaRegistry>(bootstrap).expect("cannot parse autnum bootstrap");
mem.put_bootstrap_registry(
&IanaRegistryType::RdapBootstrapAsn,
iana,
HttpData::example().build(),
)
.expect("put iana registry");
// WHEN
let actual = mem
.get_autnum_query_urls(&QueryType::autnum("as64512").expect("invalid autnum"))
.expect("get bootstrap url")
.preferred_url()
.expect("preferred url");
// THEN
assert_eq!(actual, "https://example.net/rdaprir2/");
}
#[test]
fn GIVEN_membootstrap_with_ipv4_THEN_get_ipv4_query_urls_THEN_correct_url() {
// GIVEN
let mem = MemoryBootstrapStore::new();
let bootstrap = r#"
{
"version": "1.0",
"publication": "2024-01-07T10:11:12Z",
"description": "RDAP Bootstrap file for example registries.",
"services": [
[
["198.51.100.0/24", "192.0.0.0/8"],
[
"https://rir1.example.com/myrdap/"
]
],
[
["203.0.113.0/24", "192.0.2.0/24"],
[
"https://example.org/"
]
],
[
["203.0.113.0/28"],
[
"https://example.net/rdaprir2/",
"http://example.net/rdaprir2/"
]
]
]
}
"#;
let iana =
serde_json::from_str::<IanaRegistry>(bootstrap).expect("cannot parse autnum bootstrap");
mem.put_bootstrap_registry(
&IanaRegistryType::RdapBootstrapIpv4,
iana,
HttpData::example().build(),
)
.expect("put iana registry");
// WHEN
let actual = mem
.get_ipv4_query_urls(&QueryType::ipv4("198.51.100.1").expect("invalid IP address"))
.expect("get bootstrap url")
.preferred_url()
.expect("preferred url");
// THEN
assert_eq!(actual, "https://rir1.example.com/myrdap/");
}
#[test]
fn GIVEN_membootstrap_with_ipv6_THEN_get_ipv6_query_urls_THEN_correct_url() {
// GIVEN
let mem = MemoryBootstrapStore::new();
let bootstrap = r#"
{
"version": "1.0",
"publication": "2024-01-07T10:11:12Z",
"description": "RDAP Bootstrap file for example registries.",
"services": [
[
["2001:db8::/34"],
[
"https://rir2.example.com/myrdap/"
]
],
[
["2001:db8:4000::/36", "2001:db8:ffff::/48"],
[
"https://example.org/"
]
],
[
["2001:db8:1000::/36"],
[
"https://example.net/rdaprir2/",
"http://example.net/rdaprir2/"
]
]
]
}
"#;
let iana =
serde_json::from_str::<IanaRegistry>(bootstrap).expect("cannot parse autnum bootstrap");
mem.put_bootstrap_registry(
&IanaRegistryType::RdapBootstrapIpv6,
iana,
HttpData::example().build(),
)
.expect("put iana registry");
// WHEN
let actual = mem
.get_ipv6_query_urls(&QueryType::ipv6("2001:db8::1").expect("invalid IP address"))
.expect("get bootstrap url")
.preferred_url()
.expect("preferred url");
// THEN
assert_eq!(actual, "https://rir2.example.com/myrdap/");
}
#[test]
fn GIVEN_membootstrap_with_tag_THEN_get_tag_query_urls_THEN_correct_url() {
// GIVEN
let mem = MemoryBootstrapStore::new();
let bootstrap = r#"
{
"version": "1.0",
"publication": "YYYY-MM-DDTHH:MM:SSZ",
"description": "RDAP bootstrap file for service provider object tags",
"services": [
[
["contact@example.com"],
["YYYY"],
[
"https://example.com/rdap/"
]
],
[
["contact@example.org"],
["ZZ54"],
[
"http://rdap.example.org/"
]
],
[
["contact@example.net"],
["1754"],
[
"https://example.net/rdap/",
"http://example.net/rdap/"
]
]
]
}
"#;
let iana =
serde_json::from_str::<IanaRegistry>(bootstrap).expect("cannot parse autnum bootstrap");
mem.put_bootstrap_registry(
&IanaRegistryType::RdapObjectTags,
iana,
HttpData::example().build(),
)
.expect("put iana registry");
// WHEN
let actual = mem
.get_entity_handle_query_urls(&QueryType::Entity("foo-YYYY".to_string()))
.expect("get bootstrap url")
.preferred_url()
.expect("preferred url");
// THEN
assert_eq!(actual, "https://example.com/rdap/");
}
}

View file

@ -0,0 +1,48 @@
//! The IANA RDAP Bootstrap Registries.
use {
icann_rdap_common::{
httpdata::HttpData,
iana::{IanaRegistry, IanaRegistryType, RdapBootstrapRegistry},
},
serde::{Deserialize, Serialize},
thiserror::Error,
};
use crate::http::{wrapped_request, Client};
/// Response from getting an IANA registry.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct IanaResponse {
pub registry: IanaRegistry,
pub registry_type: IanaRegistryType,
pub http_data: HttpData,
}
/// Errors from issuing a request to get an IANA registry.
#[derive(Debug, Error)]
pub enum IanaResponseError {
#[error(transparent)]
Reqwest(#[from] reqwest::Error),
#[error(transparent)]
SerdeJson(#[from] serde_json::Error),
}
/// Issues the HTTP request to get an IANA registry.
pub async fn iana_request(
registry_type: IanaRegistryType,
client: &Client,
) -> Result<IanaResponse, IanaResponseError> {
let url = registry_type.url();
let wrapped_response = wrapped_request(url, client).await?;
let text = wrapped_response.text;
let http_data = wrapped_response.http_data;
let json: RdapBootstrapRegistry = serde_json::from_str(&text)?;
Ok(IanaResponse {
registry: IanaRegistry::RdapBootstrapRegistry(json),
registry_type,
http_data,
})
}

View file

@ -0,0 +1,9 @@
//! IANA and RDAP Bootstrapping
#[doc(inline)]
pub use bootstrap::*;
#[doc(inline)]
pub use iana_request::*;
pub(crate) mod bootstrap;
pub(crate) mod iana_request;

View file

@ -0,0 +1,112 @@
#![allow(dead_code)] // TODO remove this at some point
#![allow(rustdoc::bare_urls)]
#![doc = include_str!("../README.md")]
use std::{fmt::Display, sync::PoisonError};
use {
iana::iana_request::IanaResponseError,
icann_rdap_common::{
dns_types::DomainNameError, httpdata::HttpData, iana::BootstrapRegistryError,
response::RdapResponseError,
},
thiserror::Error,
};
pub mod gtld;
pub mod http;
pub mod iana;
pub mod md;
pub mod rdap;
/// Basics necesasry for a simple clients.
pub mod prelude {
#[doc(inline)]
pub use crate::http::create_client;
#[doc(inline)]
pub use crate::http::ClientConfig;
#[doc(inline)]
pub use crate::iana::MemoryBootstrapStore;
#[doc(inline)]
pub use crate::rdap::rdap_bootstrapped_request;
#[doc(inline)]
pub use crate::rdap::rdap_request;
#[doc(inline)]
pub use crate::rdap::rdap_url_request;
#[doc(inline)]
pub use crate::rdap::QueryType;
#[doc(inline)]
pub use crate::RdapClientError;
}
/// Error returned by RDAP client functions and methods.
#[derive(Error, Debug)]
pub enum RdapClientError {
#[error("Query value is not valid.")]
InvalidQueryValue,
#[error("Ambiquous query type.")]
AmbiquousQueryType,
#[error(transparent)]
Response(#[from] RdapResponseError),
#[error(transparent)]
Client(#[from] reqwest::Error),
#[error("Error parsing response")]
ParsingError(Box<ParsingErrorInfo>),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error("RwLock Poison Error")]
Poison,
#[error("Bootstrap unavailable")]
BootstrapUnavailable,
#[error(transparent)]
BootstrapError(#[from] BootstrapRegistryError),
#[error(transparent)]
IanaResponse(#[from] IanaResponseError),
#[error(transparent)]
IoError(#[from] std::io::Error),
#[error(transparent)]
DomainNameError(#[from] DomainNameError),
}
impl<T> From<PoisonError<T>> for RdapClientError {
fn from(_err: PoisonError<T>) -> Self {
Self::Poison
}
}
/// Describes the error that occurs when parsing RDAP responses.
#[derive(Debug)]
pub struct ParsingErrorInfo {
pub text: String,
pub http_data: HttpData,
pub error: serde_json::Error,
}
impl Display for ParsingErrorInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Error: {}\n,Content Length: {}\nContent Type: {}\nUrl: {}\nText:\n{}\n",
self.error,
self.http_data
.content_length
.map_or("No content length given".to_string(), |n| n.to_string()),
self.http_data
.content_type
.clone()
.unwrap_or("No content type given".to_string()),
self.http_data.host,
self.text
)
}
}

View file

@ -0,0 +1,111 @@
use std::any::TypeId;
use icann_rdap_common::{
check::{CheckParams, GetChecks, GetSubChecks},
response::Autnum,
};
use super::{
string::StringUtil,
table::{MultiPartTable, ToMpTable},
types::checks_to_table,
FromMd, MdHeaderText, MdParams, MdUtil, ToMd, HR,
};
impl ToMd for Autnum {
fn to_md(&self, params: MdParams) -> String {
let typeid = TypeId::of::<Self>();
let mut md = String::new();
md.push_str(&self.common.to_md(params.from_parent(typeid)));
let header_text = self.get_header_text();
md.push_str(
&header_text
.to_string()
.to_header(params.heading_level, params.options),
);
// multipart data
let mut table = MultiPartTable::new();
// summary
table = table.summary(header_text);
// identifiers
table = table
.header_ref(&"Identifiers")
.and_nv_ref(
&"Start AS Number",
&self.start_autnum.as_ref().map(|n| n.to_string()),
)
.and_nv_ref(
&"End AS Number",
&self.end_autnum.as_ref().map(|n| n.to_string()),
)
.and_nv_ref(&"Handle", &self.object_common.handle)
.and_nv_ref(&"Autnum Type", &self.autnum_type)
.and_nv_ref(&"Autnum Name", &self.name)
.and_nv_ref(&"Country", &self.country);
// common object stuff
table = self.object_common.add_to_mptable(table, params);
// checks
let check_params = CheckParams::from_md(params, typeid);
let mut checks = self.object_common.get_sub_checks(check_params);
checks.push(self.get_checks(check_params));
table = checks_to_table(checks, table, params);
// render table
md.push_str(&table.to_md(params));
// remarks
md.push_str(&self.object_common.remarks.to_md(params.from_parent(typeid)));
// only other object classes from here
md.push_str(HR);
// entities
md.push_str(
&self
.object_common
.entities
.to_md(params.from_parent(typeid)),
);
// redacted
if let Some(redacted) = &self.object_common.redacted {
md.push_str(&redacted.as_slice().to_md(params.from_parent(typeid)));
}
md.push('\n');
md
}
}
impl MdUtil for Autnum {
fn get_header_text(&self) -> MdHeaderText {
let header_text = if self.start_autnum.is_some() && self.end_autnum.is_some() {
format!(
"Autonomous Systems {} - {}",
&self.start_autnum.as_ref().unwrap().replace_md_chars(),
&self.end_autnum.as_ref().unwrap().replace_md_chars()
)
} else if let Some(start_autnum) = &self.start_autnum {
format!("Autonomous System {}", start_autnum.replace_md_chars())
} else if let Some(handle) = &self.object_common.handle {
format!("Autonomous System {}", handle.replace_md_chars())
} else if let Some(name) = &self.name {
format!("Autonomous System {}", name.replace_md_chars())
} else {
"Autonomous System".to_string()
};
let mut header_text = MdHeaderText::builder().header_text(header_text);
if let Some(entities) = &self.object_common.entities {
for entity in entities {
header_text = header_text.children_entry(entity.get_header_text());
}
};
header_text.build()
}
}

View file

@ -0,0 +1,267 @@
use std::any::TypeId;
use icann_rdap_common::{
dns_types::{DnsAlgorithmType, DnsDigestType},
response::{Domain, SecureDns, Variant},
};
use icann_rdap_common::check::{CheckParams, GetChecks, GetSubChecks};
use crate::rdap::registered_redactions::{self, text_or_registered_redaction};
use super::{
redacted::REDACTED_TEXT,
string::{StringListUtil, StringUtil},
table::{MultiPartTable, ToMpTable},
types::{checks_to_table, events_to_table, links_to_table, public_ids_to_table},
FromMd, MdHeaderText, MdParams, MdUtil, ToMd, HR,
};
impl ToMd for Domain {
fn to_md(&self, params: MdParams) -> String {
let typeid = TypeId::of::<Self>();
let mut md = String::new();
md.push_str(&self.common.to_md(params.from_parent(typeid)));
// header
let header_text = self.get_header_text();
md.push_str(
&header_text
.to_string()
.to_header(params.heading_level, params.options),
);
// multipart data
let mut table = MultiPartTable::new();
let domain_handle = text_or_registered_redaction(
params.root,
&registered_redactions::RedactedName::RegistryDomainId,
&self.object_common.handle,
REDACTED_TEXT,
);
// summary
table = table.summary(header_text);
// identifiers
table = table
.header_ref(&"Identifiers")
.and_nv_ref(&"LDH Name", &self.ldh_name)
.and_nv_ref(&"Unicode Name", &self.unicode_name)
.and_nv_ref(&"Handle", &domain_handle);
if let Some(public_ids) = &self.public_ids {
table = public_ids_to_table(public_ids, table);
}
// common object stuff
table = self.object_common.add_to_mptable(table, params);
// checks
let check_params = CheckParams::from_md(params, typeid);
let mut checks = self.object_common.get_sub_checks(check_params);
checks.push(self.get_checks(check_params));
table = checks_to_table(checks, table, params);
// render table
md.push_str(&table.to_md(params));
// variants require a custom table
if let Some(variants) = &self.variants {
md.push_str(&do_variants(variants, params))
}
// secure dns
if let Some(secure_dns) = &self.secure_dns {
md.push_str(&do_secure_dns(secure_dns, params))
}
// remarks
md.push_str(&self.object_common.remarks.to_md(params.from_parent(typeid)));
// only other object classes from here
md.push_str(HR);
// entities
md.push_str(
&self
.object_common
.entities
.to_md(params.from_parent(typeid)),
);
// nameservers
if let Some(nameservers) = &self.nameservers {
nameservers
.iter()
.for_each(|ns| md.push_str(&ns.to_md(params.next_level())));
}
// network
if let Some(network) = &self.network {
md.push_str(&network.to_md(params.next_level()));
}
// redacted
if let Some(redacted) = &self.object_common.redacted {
md.push_str(&redacted.as_slice().to_md(params.from_parent(typeid)));
}
md.push('\n');
md
}
}
fn do_variants(variants: &[Variant], params: MdParams) -> String {
let mut md = String::new();
md.push_str(&format!(
"|:-:|\n|{}|\n",
"Domain Variants".to_right_bold(8, params.options)
));
md.push_str("|:-:|:-:|:-:|\n|Relations|IDN Table|Variant Names|\n");
variants.iter().for_each(|v| {
md.push_str(&format!(
"|{}|{}|{}|",
v.relations().make_title_case_list(),
v.idn_table.as_deref().unwrap_or_default(),
v.variant_names
.as_deref()
.unwrap_or_default()
.iter()
.map(|dv| format!(
"ldh: '{}' utf:'{}'",
dv.ldh_name.as_deref().unwrap_or_default(),
dv.unicode_name.as_deref().unwrap_or_default()
))
.collect::<Vec<String>>()
.join(", "),
))
});
md.push('\n');
md
}
fn do_secure_dns(secure_dns: &SecureDns, params: MdParams) -> String {
let mut md = String::new();
// multipart data
let mut table = MultiPartTable::new();
table = table
.header_ref(&"DNSSEC Information")
.and_nv_ref(
&"Zone Signed",
&secure_dns.zone_signed.as_ref().map(|b| b.to_string()),
)
.and_nv_ref(
&"Delegation Signed",
&secure_dns.delegation_signed.as_ref().map(|b| b.to_string()),
)
.and_nv_ref(
&"Max Sig Life",
&secure_dns.max_sig_life.as_ref().map(|u| u.to_string()),
);
if let Some(ds_data) = &secure_dns.ds_data {
for (i, ds) in ds_data.iter().enumerate() {
let header = format!("DS Data ({i})").replace_md_chars();
table = table
.header_ref(&header)
.and_nv_ref(&"Key Tag", &ds.key_tag.as_ref().map(|k| k.to_string()))
.and_nv_ref(
&"Algorithm",
&dns_algorithm(&ds.algorithm.as_ref().and_then(|a| a.as_u8())),
)
.and_nv_ref(&"Digest", &ds.digest)
.and_nv_ref(
&"Digest Type",
&dns_digest_type(&ds.digest_type.as_ref().and_then(|d| d.as_u8())),
);
if let Some(events) = &ds.events {
let ds_header = format!("DS ({i}) Events");
table = events_to_table(events, table, &ds_header, params);
}
if let Some(links) = &ds.links {
let ds_header = format!("DS ({i}) Links");
table = links_to_table(links, table, &ds_header);
}
}
}
if let Some(key_data) = &secure_dns.key_data {
for (i, key) in key_data.iter().enumerate() {
let header = format!("Key Data ({i})").replace_md_chars();
table = table
.header_ref(&header)
.and_nv_ref(&"Flags", &key.flags.as_ref().map(|k| k.to_string()))
.and_nv_ref(&"Protocol", &key.protocol.as_ref().map(|a| a.to_string()))
.and_nv_ref(&"Public Key", &key.public_key)
.and_nv_ref(
&"Algorithm",
&dns_algorithm(&key.algorithm.as_ref().and_then(|a| a.as_u8())),
);
if let Some(events) = &key.events {
let key_header = format!("Key ({i}) Events");
table = events_to_table(events, table, &key_header, params);
}
if let Some(links) = &key.links {
let key_header = format!("Key ({i}) Links");
table = links_to_table(links, table, &key_header);
}
}
}
// checks
let typeid = TypeId::of::<Domain>();
let check_params = CheckParams::from_md(params, typeid);
let checks = secure_dns.get_sub_checks(check_params);
table = checks_to_table(checks, table, params);
// render table
md.push_str(&table.to_md(params));
md
}
fn dns_algorithm(alg: &Option<u8>) -> Option<String> {
alg.map(|alg| {
DnsAlgorithmType::mnemonic(alg).map_or(format!("{alg} - Unassigned or Reserved"), |a| {
format!("{alg} - {a}")
})
})
}
fn dns_digest_type(dt: &Option<u8>) -> Option<String> {
dt.map(|dt| {
DnsDigestType::mnemonic(dt).map_or(format!("{dt} - Unassigned or Reserved"), |a| {
format!("{dt} - {a}")
})
})
}
impl MdUtil for Domain {
fn get_header_text(&self) -> MdHeaderText {
let header_text = if let Some(unicode_name) = &self.unicode_name {
format!("Domain {}", unicode_name.replace_md_chars())
} else if let Some(ldh_name) = &self.ldh_name {
format!("Domain {}", ldh_name.replace_md_chars())
} else if let Some(handle) = &self.object_common.handle {
format!("Domain {}", handle.replace_md_chars())
} else {
"Domain".to_string()
};
let mut header_text = MdHeaderText::builder().header_text(header_text);
if let Some(entities) = &self.object_common.entities {
for entity in entities {
header_text = header_text.children_entry(entity.get_header_text());
}
};
if let Some(nameservers) = &self.nameservers {
for ns in nameservers {
header_text = header_text.children_entry(ns.get_header_text());
}
};
if let Some(network) = &self.network {
header_text = header_text.children_entry(network.get_header_text());
}
header_text.build()
}
}

View file

@ -0,0 +1,359 @@
use std::any::TypeId;
use icann_rdap_common::{
contact::{NameParts, PostalAddress},
response::{Entity, EntityRole},
};
use icann_rdap_common::check::{CheckParams, GetChecks, GetSubChecks};
use crate::rdap::registered_redactions::{
are_redactions_registered_for_roles, is_redaction_registered_for_role,
text_or_registered_redaction_for_role, RedactedName,
};
use super::{
redacted::REDACTED_TEXT,
string::StringUtil,
table::{MultiPartTable, ToMpTable},
types::{checks_to_table, public_ids_to_table},
FromMd, MdHeaderText, MdParams, MdUtil, ToMd, HR,
};
impl ToMd for Entity {
fn to_md(&self, params: MdParams) -> String {
let typeid = TypeId::of::<Self>();
let mut md = String::new();
md.push_str(&self.common.to_md(params.from_parent(typeid)));
// header
let header_text = self.get_header_text();
md.push_str(
&header_text
.to_string()
.to_header(params.heading_level, params.options),
);
// A note about the RFC 9537 redactions. A lot of this code is to do RFC 9537 redactions
// that are registered with the IANA. As RFC 9537 is horribly broken, it is likely only
// gTLD registries will use registered redactions, and when they do they will use all
// of them. Therefore, as horribly complicated as this logic is, it attempts to simplify
// things by assuming all the registrations will be used at once, which will be the case
// in the gTLD space.
// check if registrant or tech ids are RFC 9537 redacted
let mut entity_handle = text_or_registered_redaction_for_role(
params.root,
&RedactedName::RegistryRegistrantId,
self,
&EntityRole::Registrant,
&self.object_common.handle,
REDACTED_TEXT,
);
entity_handle = text_or_registered_redaction_for_role(
params.root,
&RedactedName::RegistryTechId,
self,
&EntityRole::Technical,
&entity_handle,
REDACTED_TEXT,
);
// multipart data
let mut table = MultiPartTable::new();
// summary
table = table.summary(header_text);
// identifiers
table = table
.header_ref(&"Identifiers")
.and_nv_ref(&"Handle", &entity_handle)
.and_nv_ul(&"Roles", Some(self.roles().to_vec()));
if let Some(public_ids) = &self.public_ids {
table = public_ids_to_table(public_ids, table);
}
if let Some(contact) = self.contact() {
// nutty RFC 9537 redaction stuff
// check if registrant or tech name are redacted
let mut registrant_name = text_or_registered_redaction_for_role(
params.root,
&RedactedName::RegistrantName,
self,
&EntityRole::Registrant,
&contact.full_name,
REDACTED_TEXT,
);
registrant_name = text_or_registered_redaction_for_role(
params.root,
&RedactedName::TechName,
self,
&EntityRole::Technical,
&registrant_name,
REDACTED_TEXT,
);
// check to see if registrant postal address parts are redacted
let postal_addresses = if are_redactions_registered_for_roles(
params.root,
&[
&RedactedName::RegistrantStreet,
&RedactedName::RegistrantCity,
&RedactedName::RegistrantPostalCode,
],
self,
&[&EntityRole::Registrant],
) {
let mut new_pas = contact.postal_addresses.clone();
if let Some(ref mut new_pas) = new_pas {
new_pas.iter_mut().for_each(|pa| {
pa.street_parts = Some(vec![REDACTED_TEXT.to_string()]);
pa.locality = Some(REDACTED_TEXT.to_string());
pa.postal_code = Some(REDACTED_TEXT.to_string());
})
}
new_pas
} else {
contact.postal_addresses
};
table = table
.header_ref(&"Contact")
.and_nv_ref_maybe(&"Kind", &contact.kind)
.and_nv_ref_maybe(&"Full Name", &registrant_name)
.and_nv_ul(&"Titles", contact.titles)
.and_nv_ul(&"Org Roles", contact.roles)
.and_nv_ul(&"Nicknames", contact.nick_names);
if is_redaction_registered_for_role(
params.root,
&RedactedName::RegistrantOrganization,
self,
&EntityRole::Registrant,
) {
table = table.nv_ref(&"Organization Name", &REDACTED_TEXT.to_string());
} else {
table = table.and_nv_ul(&"Organization Names", contact.organization_names);
}
table = table.and_nv_ul(&"Languages", contact.langs);
if are_redactions_registered_for_roles(
params.root,
&[
&RedactedName::RegistrantPhone,
&RedactedName::RegistrantPhoneExt,
&RedactedName::RegistrantFax,
&RedactedName::RegistrantFaxExt,
&RedactedName::TechPhone,
&RedactedName::TechPhoneExt,
],
self,
&[&EntityRole::Registrant, &EntityRole::Technical],
) {
table = table.nv_ref(&"Phones", &REDACTED_TEXT.to_string());
} else {
table = table.and_nv_ul(&"Phones", contact.phones);
}
if are_redactions_registered_for_roles(
params.root,
&[&RedactedName::TechEmail, &RedactedName::RegistrantEmail],
self,
&[&EntityRole::Registrant, &EntityRole::Technical],
) {
table = table.nv_ref(&"Emails", &REDACTED_TEXT.to_string());
} else {
table = table.and_nv_ul(&"Emails", contact.emails);
}
table = table
.and_nv_ul(&"Web Contact", contact.contact_uris)
.and_nv_ul(&"URLs", contact.urls);
table = postal_addresses.add_to_mptable(table, params);
table = contact.name_parts.add_to_mptable(table, params)
}
// common object stuff
table = self.object_common.add_to_mptable(table, params);
// checks
let check_params = CheckParams::from_md(params, typeid);
let mut checks = self.object_common.get_sub_checks(check_params);
checks.push(self.get_checks(check_params));
table = checks_to_table(checks, table, params);
// render table
md.push_str(&table.to_md(params));
// remarks
md.push_str(&self.object_common.remarks.to_md(params.from_parent(typeid)));
// only other object classes from here
md.push_str(HR);
// entities
md.push_str(
&self
.object_common
.entities
.to_md(params.from_parent(typeid)),
);
// redacted
if let Some(redacted) = &self.object_common.redacted {
md.push_str(&redacted.as_slice().to_md(params.from_parent(typeid)));
}
md.push('\n');
md
}
}
impl ToMd for Option<Vec<Entity>> {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
if let Some(entities) = &self {
entities
.iter()
.for_each(|entity| md.push_str(&entity.to_md(params.next_level())));
}
md
}
}
impl ToMpTable for Option<Vec<PostalAddress>> {
fn add_to_mptable(&self, mut table: MultiPartTable, params: MdParams) -> MultiPartTable {
if let Some(addrs) = self {
for addr in addrs {
table = addr.add_to_mptable(table, params);
}
}
table
}
}
impl ToMpTable for PostalAddress {
fn add_to_mptable(&self, mut table: MultiPartTable, _params: MdParams) -> MultiPartTable {
if self.contexts.is_some() && self.preference.is_some() {
table = table.nv(
&"Address",
format!(
"{} (pref: {})",
self.contexts.as_ref().unwrap().join(" "),
self.preference.unwrap()
),
);
} else if self.contexts.is_some() {
table = table.nv(&"Address", self.contexts.as_ref().unwrap().join(" "));
} else if self.preference.is_some() {
table = table.nv(
&"Address",
format!("preference: {}", self.preference.unwrap()),
);
} else {
table = table.nv(&"Address", "");
}
if let Some(street_parts) = &self.street_parts {
table = table.nv_ul_ref(&"Street", street_parts.iter().collect());
}
if let Some(locality) = &self.locality {
table = table.nv_ref(&"Locality", locality);
}
if self.region_name.is_some() && self.region_code.is_some() {
table = table.nv(
&"Region",
format!(
"{} ({})",
self.region_name.as_ref().unwrap(),
self.region_code.as_ref().unwrap()
),
);
} else if let Some(region_name) = &self.region_name {
table = table.nv_ref(&"Region", region_name);
} else if let Some(region_code) = &self.region_code {
table = table.nv_ref(&"Region", region_code);
}
if self.country_name.is_some() && self.country_code.is_some() {
table = table.nv(
&"Country",
format!(
"{} ({})",
self.country_name.as_ref().unwrap(),
self.country_code.as_ref().unwrap()
),
);
} else if let Some(country_name) = &self.country_name {
table = table.nv_ref(&"Country", country_name);
} else if let Some(country_code) = &self.country_code {
table = table.nv_ref(&"Country", country_code);
}
if let Some(postal_code) = &self.postal_code {
table = table.nv_ref(&"Postal Code", postal_code);
}
if let Some(full_address) = &self.full_address {
let parts = full_address.split('\n').collect::<Vec<&str>>();
for (i, p) in parts.iter().enumerate() {
table = table.nv_ref(&i.to_string(), p);
}
}
table
}
}
impl ToMpTable for Option<NameParts> {
fn add_to_mptable(&self, mut table: MultiPartTable, _params: MdParams) -> MultiPartTable {
if let Some(parts) = self {
if let Some(prefixes) = &parts.prefixes {
table = table.nv(&"Honorifics", prefixes.join(", "));
}
if let Some(given_names) = &parts.given_names {
table = table.nv_ul(&"Given Names", given_names.to_vec());
}
if let Some(middle_names) = &parts.middle_names {
table = table.nv_ul(&"Middle Names", middle_names.to_vec());
}
if let Some(surnames) = &parts.surnames {
table = table.nv_ul(&"Surnames", surnames.to_vec());
}
if let Some(suffixes) = &parts.suffixes {
table = table.nv(&"Suffixes", suffixes.join(", "));
}
}
table
}
}
impl MdUtil for Entity {
fn get_header_text(&self) -> MdHeaderText {
let role = self
.roles()
.first()
.map(|s| s.replace_md_chars().to_title_case());
let header_text = if let Some(handle) = &self.object_common.handle {
if let Some(role) = role {
format!("{} ({})", handle.replace_md_chars(), role)
} else {
format!("Entity {}", handle)
}
} else if let Some(role) = role {
role.to_string()
} else {
"Entity".to_string()
};
let mut header_text = MdHeaderText::builder().header_text(header_text);
if let Some(entities) = &self.object_common.entities {
for entity in entities {
header_text = header_text.children_entry(entity.get_header_text());
}
};
if let Some(networks) = &self.networks {
for network in networks {
header_text = header_text.children_entry(network.get_header_text());
}
};
if let Some(autnums) = &self.autnums {
for autnum in autnums {
header_text = header_text.children_entry(autnum.get_header_text());
}
};
header_text.build()
}
}

View file

@ -0,0 +1,21 @@
use std::any::TypeId;
use icann_rdap_common::response::Rfc9083Error;
use super::{MdHeaderText, MdParams, MdUtil, ToMd, HR};
impl ToMd for Rfc9083Error {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
md.push_str(&self.common.to_md(params.from_parent(TypeId::of::<Self>())));
md.push_str(HR);
md.push('\n');
md
}
}
impl MdUtil for Rfc9083Error {
fn get_header_text(&self) -> MdHeaderText {
MdHeaderText::builder().header_text("RDAP Error").build()
}
}

View file

@ -0,0 +1,21 @@
use std::any::TypeId;
use icann_rdap_common::response::Help;
use super::{MdHeaderText, MdParams, MdUtil, ToMd, HR};
impl ToMd for Help {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
md.push_str(&self.common.to_md(params.from_parent(TypeId::of::<Self>())));
md.push_str(HR);
md.push('\n');
md
}
}
impl MdUtil for Help {
fn get_header_text(&self) -> MdHeaderText {
MdHeaderText::builder().header_text("Server Help").build()
}
}

View file

@ -0,0 +1,205 @@
//! Converts RDAP to Markdown.
use {
crate::rdap::rr::RequestData,
buildstructor::Builder,
icann_rdap_common::{check::CheckParams, httpdata::HttpData, response::RdapResponse},
std::{any::TypeId, char},
strum::EnumMessage,
};
use icann_rdap_common::check::{CheckClass, Checks, CHECK_CLASS_LEN};
use self::string::StringUtil;
pub mod autnum;
pub mod domain;
pub mod entity;
pub mod error;
pub mod help;
pub mod nameserver;
pub mod network;
pub mod redacted;
pub mod search;
pub mod string;
pub mod table;
pub mod types;
pub(crate) const _CODE_INDENT: &str = " ";
pub(crate) const HR: &str = "----------------------------------------\n";
/// Specifies options for generating markdown.
pub struct MdOptions {
/// If true, do not use Unicode characters.
pub no_unicode_chars: bool,
/// The character used for text styling of bold and italics.
pub text_style_char: char,
/// If true, headers use the hash marks or under lines.
pub hash_headers: bool,
/// If true, the text_style_char will appear in a justified text.
pub style_in_justify: bool,
}
impl Default for MdOptions {
fn default() -> Self {
Self {
no_unicode_chars: false,
text_style_char: '*',
hash_headers: true,
style_in_justify: false,
}
}
}
impl MdOptions {
/// Defaults for markdown that looks more like plain text.
pub fn plain_text() -> Self {
Self {
no_unicode_chars: true,
text_style_char: '_',
hash_headers: false,
style_in_justify: true,
}
}
}
#[derive(Clone, Copy)]
pub struct MdParams<'a> {
pub heading_level: usize,
pub root: &'a RdapResponse,
pub http_data: &'a HttpData,
pub parent_type: TypeId,
pub check_types: &'a [CheckClass],
pub options: &'a MdOptions,
pub req_data: &'a RequestData<'a>,
}
impl MdParams<'_> {
pub fn from_parent(&self, parent_type: TypeId) -> Self {
Self {
parent_type,
heading_level: self.heading_level,
root: self.root,
http_data: self.http_data,
check_types: self.check_types,
options: self.options,
req_data: self.req_data,
}
}
pub fn next_level(&self) -> Self {
Self {
heading_level: self.heading_level + 1,
..*self
}
}
}
pub trait ToMd {
fn to_md(&self, params: MdParams) -> String;
}
impl ToMd for RdapResponse {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
md.push_str(&params.http_data.to_md(params));
let variant_md = match &self {
Self::Entity(entity) => entity.to_md(params),
Self::Domain(domain) => domain.to_md(params),
Self::Nameserver(nameserver) => nameserver.to_md(params),
Self::Autnum(autnum) => autnum.to_md(params),
Self::Network(network) => network.to_md(params),
Self::DomainSearchResults(results) => results.to_md(params),
Self::EntitySearchResults(results) => results.to_md(params),
Self::NameserverSearchResults(results) => results.to_md(params),
Self::ErrorResponse(error) => error.to_md(params),
Self::Help(help) => help.to_md(params),
};
md.push_str(&variant_md);
md
}
}
pub trait MdUtil {
fn get_header_text(&self) -> MdHeaderText;
}
#[derive(Builder)]
pub struct MdHeaderText {
header_text: String,
children: Vec<MdHeaderText>,
}
#[allow(clippy::to_string_trait_impl)]
impl ToString for MdHeaderText {
fn to_string(&self) -> String {
self.header_text.clone()
}
}
impl MdUtil for RdapResponse {
fn get_header_text(&self) -> MdHeaderText {
match &self {
Self::Entity(entity) => entity.get_header_text(),
Self::Domain(domain) => domain.get_header_text(),
Self::Nameserver(nameserver) => nameserver.get_header_text(),
Self::Autnum(autnum) => autnum.get_header_text(),
Self::Network(network) => network.get_header_text(),
Self::DomainSearchResults(results) => results.get_header_text(),
Self::EntitySearchResults(results) => results.get_header_text(),
Self::NameserverSearchResults(results) => results.get_header_text(),
Self::ErrorResponse(error) => error.get_header_text(),
Self::Help(help) => help.get_header_text(),
}
}
}
pub(crate) fn checks_ul(checks: &Checks, params: MdParams) -> String {
let mut md = String::new();
checks
.items
.iter()
.filter(|item| params.check_types.contains(&item.check_class))
.for_each(|item| {
md.push_str(&format!(
"* {}: {}\n",
&item
.check_class
.to_string()
.to_right_em(*CHECK_CLASS_LEN, params.options),
item.check
.get_message()
.expect("Check has no message. Coding error.")
))
});
md
}
pub(crate) trait FromMd<'a> {
fn from_md(md_params: MdParams<'a>, parent_type: TypeId) -> Self;
fn from_md_no_parent(md_params: MdParams<'a>) -> Self;
}
impl<'a> FromMd<'a> for CheckParams<'a> {
fn from_md(md_params: MdParams<'a>, parent_type: TypeId) -> Self {
Self {
do_subchecks: false,
root: md_params.root,
parent_type,
allow_unreg_ext: false,
}
}
fn from_md_no_parent(md_params: MdParams<'a>) -> Self {
Self {
do_subchecks: false,
root: md_params.root,
parent_type: md_params.parent_type,
allow_unreg_ext: false,
}
}
}

View file

@ -0,0 +1,106 @@
use std::any::TypeId;
use icann_rdap_common::response::Nameserver;
use icann_rdap_common::check::{CheckParams, GetChecks, GetSubChecks};
use super::{
string::StringUtil,
table::{MultiPartTable, ToMpTable},
types::checks_to_table,
FromMd, MdHeaderText, MdParams, MdUtil, ToMd, HR,
};
impl ToMd for Nameserver {
fn to_md(&self, params: MdParams) -> String {
let typeid = TypeId::of::<Self>();
let mut md = String::new();
// other common stuff
md.push_str(&self.common.to_md(params.from_parent(typeid)));
// header
let header_text = self.get_header_text();
md.push_str(
&header_text
.to_string()
.to_header(params.heading_level, params.options),
);
// multipart data
let mut table = MultiPartTable::new();
// summary
table = table.summary(header_text);
// identifiers
table = table
.header_ref(&"Identifiers")
.and_nv_ref(&"LDH Name", &self.ldh_name)
.and_nv_ref(&"Unicode Name", &self.unicode_name)
.and_nv_ref(&"Handle", &self.object_common.handle);
if let Some(addresses) = &self.ip_addresses {
if let Some(v4) = &addresses.v4 {
table = table.nv_ul_ref(&"Ipv4", v4.vec().iter().collect());
}
if let Some(v6) = &addresses.v6 {
table = table.nv_ul_ref(&"Ipv6", v6.vec().iter().collect());
}
}
// common object stuff
table = self.object_common.add_to_mptable(table, params);
// checks
let check_params = CheckParams::from_md(params, typeid);
let mut checks = self.object_common.get_sub_checks(check_params);
checks.push(self.get_checks(check_params));
table = checks_to_table(checks, table, params);
// render table
md.push_str(&table.to_md(params));
// remarks
md.push_str(&self.object_common.remarks.to_md(params.from_parent(typeid)));
// only other object classes from here
md.push_str(HR);
// entities
md.push_str(
&self
.object_common
.entities
.to_md(params.from_parent(typeid)),
);
// redacted
if let Some(redacted) = &self.object_common.redacted {
md.push_str(&redacted.as_slice().to_md(params.from_parent(typeid)));
}
md.push('\n');
md
}
}
impl MdUtil for Nameserver {
fn get_header_text(&self) -> MdHeaderText {
let header_text = if let Some(unicode_name) = &self.unicode_name {
format!("Nameserver {}", unicode_name.replace_md_chars())
} else if let Some(ldh_name) = &self.ldh_name {
format!("Nameserver {}", ldh_name.replace_md_chars())
} else if let Some(handle) = &self.object_common.handle {
format!("Nameserver {}", handle.replace_md_chars())
} else {
"Domain".to_string()
};
let mut header_text = MdHeaderText::builder().header_text(header_text);
if let Some(entities) = &self.object_common.entities {
for entity in entities {
header_text = header_text.children_entry(entity.get_header_text());
}
};
header_text.build()
}
}

View file

@ -0,0 +1,108 @@
use std::any::TypeId;
use icann_rdap_common::{
check::{CheckParams, GetChecks, GetSubChecks},
response::Network,
};
use super::{
string::StringUtil,
table::{MultiPartTable, ToMpTable},
types::checks_to_table,
FromMd, MdHeaderText, MdParams, MdUtil, ToMd, HR,
};
impl ToMd for Network {
fn to_md(&self, params: MdParams) -> String {
let typeid = TypeId::of::<Self>();
let mut md = String::new();
md.push_str(&self.common.to_md(params));
let header_text = self.get_header_text();
md.push_str(
&header_text
.to_string()
.to_header(params.heading_level, params.options),
);
// multipart data
let mut table = MultiPartTable::new();
// summary
table = table.summary(header_text);
// identifiers
table = table
.header_ref(&"Identifiers")
.and_nv_ref(&"Start Address", &self.start_address)
.and_nv_ref(&"End Address", &self.end_address)
.and_nv_ref(&"IP Version", &self.ip_version)
.and_nv_ul(&"CIDR", self.cidr0_cidrs.clone())
.and_nv_ref(&"Handle", &self.object_common.handle)
.and_nv_ref(&"Parent Handle", &self.parent_handle)
.and_nv_ref(&"Network Type", &self.network_type)
.and_nv_ref(&"Network Name", &self.name)
.and_nv_ref(&"Country", &self.country);
// common object stuff
table = self.object_common.add_to_mptable(table, params);
// checks
let check_params = CheckParams::from_md(params, typeid);
let mut checks = self.object_common.get_sub_checks(check_params);
checks.push(self.get_checks(check_params));
table = checks_to_table(checks, table, params);
// render table
md.push_str(&table.to_md(params));
// remarks
md.push_str(&self.object_common.remarks.to_md(params.from_parent(typeid)));
// only other object classes from here
md.push_str(HR);
// entities
md.push_str(
&self
.object_common
.entities
.to_md(params.from_parent(typeid)),
);
// redacted
if let Some(redacted) = &self.object_common.redacted {
md.push_str(&redacted.as_slice().to_md(params.from_parent(typeid)));
}
md.push('\n');
md
}
}
impl MdUtil for Network {
fn get_header_text(&self) -> MdHeaderText {
let header_text = if self.start_address.is_some() && self.end_address.is_some() {
format!(
"IP Network {} - {}",
&self.start_address.as_ref().unwrap().replace_md_chars(),
&self.end_address.as_ref().unwrap().replace_md_chars()
)
} else if let Some(start_address) = &self.start_address {
format!("IP Network {}", start_address.replace_md_chars())
} else if let Some(handle) = &self.object_common.handle {
format!("IP Network {}", handle.replace_md_chars())
} else if let Some(name) = &self.name {
format!("IP Network {}", name.replace_md_chars())
} else {
"IP Network".to_string()
};
let mut header_text = MdHeaderText::builder().header_text(header_text);
if let Some(entities) = &self.object_common.entities {
for entity in entities {
header_text = header_text.children_entry(entity.get_header_text());
}
};
header_text.build()
}
}

View file

@ -0,0 +1,279 @@
use std::str::FromStr;
use {
icann_rdap_common::response::redacted::Redacted,
jsonpath::replace_with,
jsonpath_lib as jsonpath,
jsonpath_rust::{JsonPathFinder, JsonPathInst},
serde_json::{json, Value},
};
use {
super::{string::StringUtil, table::MultiPartTable, MdOptions, MdParams, ToMd},
icann_rdap_common::response::RdapResponse,
};
/// The text to appear if something is redacted.
///
/// This should be REDACTED in bold.
pub const REDACTED_TEXT: &str = "*REDACTED*";
impl ToMd for &[Redacted] {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
// header
let header_text = "Redacted".to_string();
md.push_str(&header_text.to_header(params.heading_level, params.options));
// multipart data
let mut table = MultiPartTable::new();
table = table.header_ref(&"Fields");
for (index, redacted) in self.iter().enumerate() {
let options = MdOptions {
text_style_char: '*',
..Default::default()
};
// make the name bold
let name = "Redaction";
let b_name = name.to_bold(&options);
// build the table
table = table.and_nv_ref(&b_name, &Some((index + 1).to_string()));
// Get the data itself
let name_data = redacted
.name
.description
.clone()
.or(redacted.name.type_field.clone());
let method_data = redacted.method.as_ref().map(|m| m.to_string());
let reason_data = redacted.reason.as_ref().map(|m| m.to_string());
// Special case the 'column' fields
table = table
.and_nv_ref(&"name".to_title_case(), &name_data)
.and_nv_ref(&"prePath".to_title_case(), &redacted.pre_path)
.and_nv_ref(&"postPath".to_title_case(), &redacted.post_path)
.and_nv_ref(
&"replacementPath".to_title_case(),
&redacted.replacement_path,
)
.and_nv_ref(&"pathLang".to_title_case(), &redacted.path_lang)
.and_nv_ref(&"method".to_title_case(), &method_data)
.and_nv_ref(&"reason".to_title_case(), &reason_data);
// we don't have these right now but if we put them in later we will need them
// let check_params = CheckParams::from_md(params, typeid);
// let mut checks = redacted.object_common.get_sub_checks(check_params);
// checks.push(redacted.get_checks(check_params));
// table = checks_to_table(checks, table, params);
}
// render table
md.push_str(&table.to_md(params));
md.push('\n');
md
}
}
// this is our public entry point
pub fn replace_redacted_items(orignal_response: RdapResponse) -> RdapResponse {
// convert the RdapResponse to a string
let rdap_json = serde_json::to_string(&orignal_response).unwrap();
// Redaction is not a top-level entity so we have to check the JSON
// to see if anything exists in the way of "redacted", this should find it in the rdapConformance
if !rdap_json.contains("\"redacted\"") {
// If there are no redactions, return the original response
return orignal_response;
}
// convert the string to a JSON Value
let mut rdap_json_response: Value = serde_json::from_str(&rdap_json).unwrap();
// this double checks to see if "redacted" is an array
if rdap_json_response["redacted"].as_array().is_none() {
// If "redacted" is not an array, return the original response
return orignal_response;
}
// Initialize the final response with the original response
let mut response = orignal_response;
// pull the redacted array out of the JSON
let redacted_array_option = rdap_json_response["redacted"].as_array().cloned();
// if there are any redactions we need to do some modifications
if let Some(ref redacted_array) = redacted_array_option {
let new_json_response = convert_redactions(&mut rdap_json_response, redacted_array).clone();
// convert the Value back to a RdapResponse
response = serde_json::from_value(new_json_response).unwrap();
}
// send the response back so we can display it to the client
response
}
fn convert_redactions<'a>(
rdap_json_response: &'a mut Value,
redacted_array: &'a [Value],
) -> &'a mut Value {
for item in redacted_array {
let item_map = item.as_object().unwrap();
let post_path = get_string_from_map(item_map, "postPath");
let method = get_string_from_map(item_map, "method");
if let Some(path_lang) = item_map.get("pathLang") {
if let Some(path_lang) = path_lang.as_str() {
if !path_lang.eq_ignore_ascii_case("jsonpath") {
continue;
}
}
}
// if method doesn't equal emptyValue or partialValue, we don't need to do anything, we can skip to the next item
if method != "emptyValue" && method != "partialValue" && !post_path.is_empty() {
continue;
}
match JsonPathInst::from_str(&post_path) {
Ok(json_path) => {
let finder =
JsonPathFinder::new(Box::new(rdap_json_response.clone()), Box::new(json_path));
let matches = finder.find_as_path();
if let Value::Array(paths) = matches {
if paths.is_empty() {
continue; // we don't need to do anything, we can skip to the next item
} else {
for path_value in paths {
if let Value::String(found_path) = path_value {
let no_value = Value::String("NO_VALUE".to_string());
let json_pointer = convert_to_json_pointer_path(&found_path);
let value_at_path = rdap_json_response
.pointer(&json_pointer)
.unwrap_or(&no_value);
if value_at_path.is_string() {
// grab the value at the end point of the JSON path
let end_of_path_value =
match rdap_json_response.pointer(&json_pointer) {
Some(value) => value.clone(),
None => {
continue;
}
};
let replaced_json = replace_with(
rdap_json_response.clone(),
&found_path,
&mut |x| {
// STRING ONLY! This is the only spot where we are ACTUALLY replacing or updating something
if x.is_string() {
match x.as_str() {
Some("") => Some(json!("*REDACTED*")),
Some(s) => Some(json!(format!("*{}*", s))),
_ => Some(json!("*REDACTED*")),
}
} else {
Some(end_of_path_value.clone()) // it isn't a string, put it back in there
}
},
);
match replaced_json {
Ok(new_json) => *rdap_json_response = new_json,
_ => {
// why did we fail to modify the JSON?
}
};
}
}
}
}
}
}
_ => {
// do nothing
}
}
}
rdap_json_response
}
// utility functions
fn convert_to_json_pointer_path(path: &str) -> String {
let pointer_path = path
.trim_start_matches('$')
.replace('.', "/")
.replace("['", "/")
.replace("']", "")
.replace('[', "/")
.replace(']', "")
.replace("//", "/");
pointer_path
}
fn get_string_from_map(map: &serde_json::Map<String, Value>, key: &str) -> String {
map.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default()
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use {
serde_json::Value,
std::{error::Error, fs::File, io::Read},
};
fn process_redacted_file(file_path: &str) -> Result<String, Box<dyn Error>> {
let mut file = File::open(file_path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
// this has to be setup very specifically, just like replace_redacted_items is setup.
let mut rdap_json_response: Value = serde_json::from_str(&contents)?;
let redacted_array_option = rdap_json_response["redacted"].as_array().cloned();
// we are testing parse_redacted_json here -- just the JSON transforms
if let Some(redacted_array) = redacted_array_option {
crate::md::redacted::convert_redactions(&mut rdap_json_response, &redacted_array);
} else {
panic!("No redacted array found in the JSON");
}
let pretty_json = serde_json::to_string_pretty(&rdap_json_response)?;
println!("{}", pretty_json);
Ok(pretty_json)
}
#[test]
fn test_process_empty_value() {
let expected_output =
std::fs::read_to_string("src/test_files/example-1_empty_value-expected.json").unwrap();
let output = process_redacted_file("src/test_files/example-1_empty_value.json").unwrap();
assert_eq!(output, expected_output);
}
#[test]
fn test_process_partial_value() {
let expected_output =
std::fs::read_to_string("src/test_files/example-2_partial_value-expected.json")
.unwrap();
let output = process_redacted_file("src/test_files/example-2_partial_value.json").unwrap();
assert_eq!(output, expected_output);
}
#[test]
fn test_process_dont_replace_number() {
let expected_output = std::fs::read_to_string(
"src/test_files/example-3-dont_replace_redaction_of_a_number.json",
)
.unwrap();
// we don't need an expected for this one, it should remain unchanged
let output = process_redacted_file(
"src/test_files/example-3-dont_replace_redaction_of_a_number.json",
)
.unwrap();
assert_eq!(output, expected_output);
}
}

View file

@ -0,0 +1,82 @@
use std::any::TypeId;
use icann_rdap_common::response::{
DomainSearchResults, EntitySearchResults, NameserverSearchResults,
};
use super::{MdHeaderText, MdParams, MdUtil, ToMd};
impl ToMd for DomainSearchResults {
fn to_md(&self, params: MdParams) -> String {
let typeid = TypeId::of::<Self>();
let mut md = String::new();
md.push_str(&self.common.to_md(params.from_parent(typeid)));
self.results.iter().for_each(|result| {
md.push_str(&result.to_md(MdParams {
heading_level: params.heading_level + 1,
parent_type: typeid,
..params
}))
});
md.push('\n');
md
}
}
impl ToMd for NameserverSearchResults {
fn to_md(&self, params: MdParams) -> String {
let typeid = TypeId::of::<Self>();
let mut md = String::new();
md.push_str(&self.common.to_md(params.from_parent(typeid)));
self.results.iter().for_each(|result| {
md.push_str(&result.to_md(MdParams {
heading_level: params.heading_level + 1,
parent_type: typeid,
..params
}))
});
md.push('\n');
md
}
}
impl ToMd for EntitySearchResults {
fn to_md(&self, params: MdParams) -> String {
let typeid = TypeId::of::<Self>();
let mut md = String::new();
md.push_str(&self.common.to_md(params.from_parent(typeid)));
self.results.iter().for_each(|result| {
md.push_str(&result.to_md(MdParams {
heading_level: params.heading_level + 1,
parent_type: typeid,
..params
}))
});
md.push('\n');
md
}
}
impl MdUtil for DomainSearchResults {
fn get_header_text(&self) -> MdHeaderText {
MdHeaderText::builder()
.header_text("Domain Search Results")
.build()
}
}
impl MdUtil for EntitySearchResults {
fn get_header_text(&self) -> MdHeaderText {
MdHeaderText::builder()
.header_text("Entity Search Results")
.build()
}
}
impl MdUtil for NameserverSearchResults {
fn get_header_text(&self) -> MdHeaderText {
MdHeaderText::builder()
.header_text("Nameserver Search Results")
.build()
}
}

View file

@ -0,0 +1,267 @@
use chrono::DateTime;
use super::{MdOptions, MdParams};
pub trait StringUtil {
/// Replaces and filters markdown characters.
fn replace_md_chars(self) -> String;
fn to_em(self, options: &MdOptions) -> String;
fn to_bold(self, options: &MdOptions) -> String;
fn to_inline(self, options: &MdOptions) -> String;
fn to_header(self, level: usize, options: &MdOptions) -> String;
fn to_right(self, width: usize, options: &MdOptions) -> String;
fn to_right_em(self, width: usize, options: &MdOptions) -> String;
fn to_right_bold(self, width: usize, options: &MdOptions) -> String;
fn to_left(self, width: usize, options: &MdOptions) -> String;
fn to_left_em(self, width: usize, options: &MdOptions) -> String;
fn to_left_bold(self, width: usize, options: &MdOptions) -> String;
fn to_center(self, width: usize, options: &MdOptions) -> String;
fn to_center_em(self, width: usize, options: &MdOptions) -> String;
fn to_center_bold(self, width: usize, options: &MdOptions) -> String;
fn to_title_case(self) -> String;
fn to_words_title_case(self) -> String;
fn to_cap_acronyms(self) -> String;
fn format_date_time(self, params: MdParams) -> Option<String>;
}
impl<T: ToString> StringUtil for T {
fn replace_md_chars(self) -> String {
self.to_string()
.replace(|c: char| c.is_whitespace(), " ")
.chars()
.map(|c| match c {
'*' | '_' | '|' | '#' => format!("\\{c}"),
_ => c.to_string(),
})
.collect()
}
fn to_em(self, options: &MdOptions) -> String {
format!(
"{}{}{}",
options.text_style_char,
self.to_string(),
options.text_style_char
)
}
fn to_bold(self, options: &MdOptions) -> String {
format!(
"{}{}{}{}{}",
options.text_style_char,
options.text_style_char,
self.to_string(),
options.text_style_char,
options.text_style_char
)
}
fn to_inline(self, _options: &MdOptions) -> String {
format!("`{}`", self.to_string(),)
}
fn to_header(self, level: usize, options: &MdOptions) -> String {
let s = self.to_string();
if options.hash_headers {
format!("{} {s}\n\n", "#".repeat(level))
} else {
let line = if level == 1 {
"=".repeat(s.len())
} else {
"-".repeat(s.len())
};
format!("{s}\n{line}\n\n")
}
}
fn to_right(self, width: usize, options: &MdOptions) -> String {
let str = self.to_string();
if options.no_unicode_chars {
format!("{str:>width$}")
} else {
format!("{str:\u{2003}>width$}")
}
}
fn to_right_em(self, width: usize, options: &MdOptions) -> String {
if options.style_in_justify {
self.to_em(options).to_right(width, options)
} else {
self.to_right(width, options).to_em(options)
}
}
fn to_right_bold(self, width: usize, options: &MdOptions) -> String {
if options.style_in_justify {
self.to_bold(options).to_right(width, options)
} else {
self.to_right(width, options).to_bold(options)
}
}
fn to_left(self, width: usize, options: &MdOptions) -> String {
let str = self.to_string();
if options.no_unicode_chars {
format!("{str:<width$}")
} else {
format!("{str:\u{2003}<width$}")
}
}
fn to_left_em(self, width: usize, options: &MdOptions) -> String {
if options.style_in_justify {
self.to_em(options).to_left(width, options)
} else {
self.to_left(width, options).to_em(options)
}
}
fn to_left_bold(self, width: usize, options: &MdOptions) -> String {
if options.style_in_justify {
self.to_bold(options).to_left(width, options)
} else {
self.to_left(width, options).to_bold(options)
}
}
fn to_center(self, width: usize, options: &MdOptions) -> String {
let str = self.to_string();
if options.no_unicode_chars {
format!("{str:^width$}")
} else {
format!("{str:\u{2003}^width$}")
}
}
fn to_center_em(self, width: usize, options: &MdOptions) -> String {
if options.style_in_justify {
self.to_em(options).to_center(width, options)
} else {
self.to_center(width, options).to_bold(options)
}
}
fn to_center_bold(self, width: usize, options: &MdOptions) -> String {
if options.style_in_justify {
self.to_bold(options).to_center(width, options)
} else {
self.to_center(width, options).to_bold(options)
}
}
fn to_title_case(self) -> String {
self.to_string()
.char_indices()
.map(|(i, mut c)| {
if i == 0 {
c.make_ascii_uppercase();
c
} else {
c
}
})
.collect::<String>()
}
fn to_words_title_case(self) -> String {
self.to_string()
.split_whitespace()
.map(|s| s.to_title_case())
.collect::<Vec<String>>()
.join(" ")
}
fn format_date_time(self, _params: MdParams) -> Option<String> {
let date = DateTime::parse_from_rfc3339(&self.to_string()).ok()?;
Some(date.format("%a, %v %X %Z").to_string())
}
fn to_cap_acronyms(self) -> String {
self.to_string()
.replace_md_chars()
.replace("rdap", "RDAP")
.replace("icann", "ICANN")
.replace("arin", "ARIN")
.replace("ripe", "RIPE")
.replace("apnic", "APNIC")
.replace("lacnic", "LACNIC")
.replace("afrinic", "AFRINIC")
.replace("nro", "NRO")
.replace("ietf", "IETF")
}
}
pub(crate) trait StringListUtil {
fn make_list_all_title_case(self) -> Vec<String>;
fn make_title_case_list(self) -> String;
}
impl<T: ToString> StringListUtil for &[T] {
fn make_list_all_title_case(self) -> Vec<String> {
self.iter()
.map(|s| s.to_string().to_words_title_case())
.collect::<Vec<String>>()
}
fn make_title_case_list(self) -> String {
self.make_list_all_title_case().join(", ")
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::{StringListUtil, StringUtil};
#[rstest]
#[case("foo", "Foo")]
#[case("FOO", "FOO")]
fn test_words(#[case] word: &str, #[case] expected: &str) {
// GIVEN in arguments
// WHEN
let actual = word.to_title_case();
// THEN
assert_eq!(actual, expected);
}
#[rstest]
#[case("foo bar", "Foo Bar")]
#[case("foo bar", "Foo Bar")]
#[case("foO baR", "FoO BaR")]
fn test_sentences(#[case] sentence: &str, #[case] expected: &str) {
// GIVEN in arguments
// WHEN
let actual = sentence.to_words_title_case();
// THEN
assert_eq!(actual, expected);
}
#[test]
fn test_list_of_sentences() {
// GIVEN
let v = ["foo bar", "foO baR"];
// WHEN
let actual = v.make_list_all_title_case();
// THEN
assert_eq!(actual, vec!["Foo Bar".to_string(), "FoO BaR".to_string()])
}
#[test]
fn test_list() {
// GIVEN
let list = ["foo bar", "bizz buzz"];
// WHEN
let actual = list.make_title_case_list();
// THEN
assert_eq!(actual, "Foo Bar, Bizz Buzz");
}
}

View file

@ -0,0 +1,477 @@
use std::cmp::max;
use super::{string::StringUtil, MdHeaderText, MdOptions, MdParams, ToMd};
pub(crate) trait ToMpTable {
fn add_to_mptable(&self, table: MultiPartTable, params: MdParams) -> MultiPartTable;
}
/// A datastructue to hold various row types for a markdown table.
///
/// This datastructure has the following types of rows:
/// * header - just the left most column which is centered and bolded text
/// * name/value - first column is the name and the second column is data.
///
/// For name/value rows, the name is right justified. Name/value rows may also
/// have unordered (bulleted) lists. In markdown, there is no such thing as a
/// multiline row, so this creates multiple rows where the name is left blank.
pub struct MultiPartTable {
rows: Vec<Row>,
}
enum Row {
Header(String),
NameValue((String, String)),
MultiValue(Vec<String>),
}
impl Default for MultiPartTable {
fn default() -> Self {
Self::new()
}
}
impl MultiPartTable {
pub fn new() -> Self {
Self { rows: vec![] }
}
/// Add a header row.
pub fn header_ref(mut self, name: &impl ToString) -> Self {
self.rows.push(Row::Header(name.to_string()));
self
}
/// Add a name/value row.
pub fn nv_ref(mut self, name: &impl ToString, value: &impl ToString) -> Self {
self.rows.push(Row::NameValue((
name.to_string(),
value.to_string().replace_md_chars(),
)));
self
}
/// Add a name/value row.
pub fn nv(mut self, name: &impl ToString, value: impl ToString) -> Self {
self.rows.push(Row::NameValue((
name.to_string(),
value.to_string().replace_md_chars(),
)));
self
}
/// Add a name/value row without processing whitespace or markdown charaters.
pub fn nv_raw(mut self, name: &impl ToString, value: impl ToString) -> Self {
self.rows
.push(Row::NameValue((name.to_string(), value.to_string())));
self
}
/// Add a name/value row with unordered list.
pub fn nv_ul_ref(mut self, name: &impl ToString, value: Vec<&impl ToString>) -> Self {
value.iter().enumerate().for_each(|(i, v)| {
if i == 0 {
self.rows.push(Row::NameValue((
name.to_string(),
format!("* {}", v.to_string().replace_md_chars()),
)))
} else {
self.rows.push(Row::NameValue((
String::default(),
format!("* {}", v.to_string().replace_md_chars()),
)))
}
});
self
}
/// Add a name/value row with unordered list.
pub fn nv_ul(mut self, name: &impl ToString, value: Vec<impl ToString>) -> Self {
value.iter().enumerate().for_each(|(i, v)| {
if i == 0 {
self.rows.push(Row::NameValue((
name.to_string(),
format!("* {}", v.to_string().replace_md_chars()),
)))
} else {
self.rows.push(Row::NameValue((
String::default(),
format!("* {}", v.to_string().replace_md_chars()),
)))
}
});
self
}
/// Add a name/value row.
pub fn and_nv_ref(mut self, name: &impl ToString, value: &Option<String>) -> Self {
self.rows.push(Row::NameValue((
name.to_string(),
value
.as_deref()
.unwrap_or_default()
.to_string()
.replace_md_chars(),
)));
self
}
/// Add a name/value row.
pub fn and_nv_ref_maybe(self, name: &impl ToString, value: &Option<String>) -> Self {
if let Some(value) = value {
self.nv_ref(name, value)
} else {
self
}
}
/// Add a name/value row with unordered list.
pub fn and_nv_ul_ref(self, name: &impl ToString, value: Option<Vec<&impl ToString>>) -> Self {
if let Some(value) = value {
self.nv_ul_ref(name, value)
} else {
self
}
}
/// Add a name/value row with unordered list.
pub fn and_nv_ul(self, name: &impl ToString, value: Option<Vec<impl ToString>>) -> Self {
if let Some(value) = value {
self.nv_ul(name, value)
} else {
self
}
}
/// A summary row is a special type of name/value row that has an unordered (bulleted) list
/// that is output in a tree structure (max 3 levels).
pub fn summary(mut self, header_text: MdHeaderText) -> Self {
self.rows.push(Row::NameValue((
"Summary".to_string(),
header_text.to_string().replace_md_chars().to_string(),
)));
// note that termimad has limits on list depth, so we can't go too crazy.
// however, this seems perfectly reasonable for must RDAP use cases.
for level1 in header_text.children {
self.rows.push(Row::NameValue((
"".to_string(),
format!("* {}", level1.to_string().replace_md_chars()),
)));
for level2 in level1.children {
self.rows.push(Row::NameValue((
"".to_string(),
format!(" * {}", level2.to_string().replace_md_chars()),
)));
}
}
self
}
/// Adds a multivalue row.
pub fn multi(mut self, values: Vec<String>) -> Self {
self.rows.push(Row::MultiValue(
values.iter().map(|s| s.replace_md_chars()).collect(),
));
self
}
/// Adds a multivalue row.
pub fn multi_ref(mut self, values: &[&str]) -> Self {
self.rows.push(Row::MultiValue(
values.iter().map(|s| s.replace_md_chars()).collect(),
));
self
}
/// Adds a multivalue row without processing whitespace or markdown characters.
pub fn multi_raw(mut self, values: Vec<String>) -> Self {
self.rows.push(Row::MultiValue(
values.iter().map(|s| s.to_owned()).collect(),
));
self
}
/// Adds a multivalue row without processing whitespace or markdown characters.
pub fn multi_raw_ref(mut self, values: &[&str]) -> Self {
self.rows.push(Row::MultiValue(
values.iter().map(|s| s.to_string()).collect(),
));
self
}
pub fn to_md_table(&self, options: &MdOptions) -> String {
let mut md = String::new();
let col_type_width = max(
self.rows
.iter()
.map(|row| match row {
Row::Header(header) => header.len(),
Row::NameValue((name, _value)) => name.len(),
Row::MultiValue(_) => 1,
})
.max()
.unwrap_or(1),
1,
);
self.rows
.iter()
.scan(true, |state, x| {
let new_state = match x {
Row::Header(name) => {
md.push_str(&format!(
"|:-:|\n|{}|\n",
name.to_center_bold(col_type_width, options)
));
true
}
Row::NameValue((name, value)) => {
if *state {
md.push_str("|-:|:-|\n");
};
md.push_str(&format!(
"|{}|{}|\n",
name.to_right(col_type_width, options),
value
));
false
}
Row::MultiValue(values) => {
// column formatting
md.push('|');
for _col in values {
md.push_str(":--:|");
}
md.push('\n');
// the actual data
md.push('|');
for col in values {
md.push_str(&format!("{col}|"));
}
md.push('\n');
true
}
};
*state = new_state;
Some(new_state)
})
.last();
md.push_str("|\n\n");
md
}
}
impl ToMd for MultiPartTable {
fn to_md(&self, params: super::MdParams) -> String {
self.to_md_table(params.options)
}
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use icann_rdap_common::{httpdata::HttpData, prelude::ToResponse, response::Rfc9083Error};
use crate::{
md::ToMd,
rdap::rr::{RequestData, SourceType},
};
use super::MultiPartTable;
#[test]
fn GIVEN_header_WHEN_to_md_THEN_header_format_and_header() {
// GIVEN
let table = MultiPartTable::new().header_ref(&"foo");
// WHEN
let req_data = RequestData {
req_number: 0,
source_host: "",
source_type: SourceType::UncategorizedRegistry,
};
let rdap_response = Rfc9083Error::builder()
.error_code(500)
.build()
.to_response();
let actual = table.to_md(crate::md::MdParams {
heading_level: 0,
root: &rdap_response,
http_data: &HttpData::example().build(),
parent_type: std::any::TypeId::of::<crate::md::MdParams>(),
check_types: &[],
options: &crate::md::MdOptions::plain_text(),
req_data: &req_data,
});
assert_eq!(actual, "|:-:|\n|__foo__|\n|\n\n")
}
#[test]
fn GIVEN_header_and_data_ref_WHEN_to_md_THEN_header_format_and_header() {
// GIVEN
let table = MultiPartTable::new()
.header_ref(&"foo")
.nv_ref(&"bizz", &"buzz");
// WHEN
let req_data = RequestData {
req_number: 0,
source_host: "",
source_type: SourceType::UncategorizedRegistry,
};
let rdap_response = Rfc9083Error::builder()
.error_code(500)
.build()
.to_response();
let actual = table.to_md(crate::md::MdParams {
heading_level: 0,
root: &rdap_response,
http_data: &HttpData::example().build(),
parent_type: std::any::TypeId::of::<crate::md::MdParams>(),
check_types: &[],
options: &crate::md::MdOptions::plain_text(),
req_data: &req_data,
});
assert_eq!(actual, "|:-:|\n|__foo__|\n|-:|:-|\n|bizz|buzz|\n|\n\n")
}
#[test]
fn GIVEN_header_and_2_data_ref_WHEN_to_md_THEN_header_format_and_header() {
// GIVEN
let table = MultiPartTable::new()
.header_ref(&"foo")
.nv_ref(&"bizz", &"buzz")
.nv_ref(&"bar", &"baz");
// WHEN
let req_data = RequestData {
req_number: 0,
source_host: "",
source_type: SourceType::UncategorizedRegistry,
};
let rdap_response = Rfc9083Error::builder()
.error_code(500)
.build()
.to_response();
let actual = table.to_md(crate::md::MdParams {
heading_level: 0,
root: &rdap_response,
http_data: &HttpData::example().build(),
parent_type: std::any::TypeId::of::<crate::md::MdParams>(),
check_types: &[],
options: &crate::md::MdOptions::plain_text(),
req_data: &req_data,
});
assert_eq!(
actual,
"|:-:|\n|__foo__|\n|-:|:-|\n|bizz|buzz|\n| bar|baz|\n|\n\n"
)
}
#[test]
fn GIVEN_header_and_data_WHEN_to_md_THEN_header_format_and_header() {
// GIVEN
let table = MultiPartTable::new()
.header_ref(&"foo")
.nv(&"bizz", "buzz".to_string());
// WHEN
let req_data = RequestData {
req_number: 0,
source_host: "",
source_type: SourceType::UncategorizedRegistry,
};
let rdap_response = Rfc9083Error::builder()
.error_code(500)
.build()
.to_response();
let actual = table.to_md(crate::md::MdParams {
heading_level: 0,
root: &rdap_response,
http_data: &HttpData::example().build(),
parent_type: std::any::TypeId::of::<crate::md::MdParams>(),
check_types: &[],
options: &crate::md::MdOptions::plain_text(),
req_data: &req_data,
});
assert_eq!(actual, "|:-:|\n|__foo__|\n|-:|:-|\n|bizz|buzz|\n|\n\n")
}
#[test]
fn GIVEN_header_and_2_data_WHEN_to_md_THEN_header_format_and_header() {
// GIVEN
let table = MultiPartTable::new()
.header_ref(&"foo")
.nv(&"bizz", "buzz")
.nv(&"bar", "baz");
// WHEN
let req_data = RequestData {
req_number: 0,
source_host: "",
source_type: SourceType::UncategorizedRegistry,
};
let rdap_response = Rfc9083Error::builder()
.error_code(500)
.build()
.to_response();
let actual = table.to_md(crate::md::MdParams {
heading_level: 0,
root: &rdap_response,
http_data: &HttpData::example().build(),
parent_type: std::any::TypeId::of::<crate::md::MdParams>(),
check_types: &[],
options: &crate::md::MdOptions::plain_text(),
req_data: &req_data,
});
assert_eq!(
actual,
"|:-:|\n|__foo__|\n|-:|:-|\n|bizz|buzz|\n| bar|baz|\n|\n\n"
)
}
#[test]
fn GIVEN_header_and_2_data_ref_twice_WHEN_to_md_THEN_header_format_and_header() {
// GIVEN
let table = MultiPartTable::new()
.header_ref(&"foo")
.nv_ref(&"bizz", &"buzz")
.nv_ref(&"bar", &"baz")
.header_ref(&"foo")
.nv_ref(&"bizz", &"buzz")
.nv_ref(&"bar", &"baz");
// WHEN
let req_data = RequestData {
req_number: 0,
source_host: "",
source_type: SourceType::UncategorizedRegistry,
};
let rdap_response = Rfc9083Error::builder()
.error_code(500)
.build()
.to_response();
let actual = table.to_md(crate::md::MdParams {
heading_level: 0,
root: &rdap_response,
http_data: &HttpData::example().build(),
parent_type: std::any::TypeId::of::<crate::md::MdParams>(),
check_types: &[],
options: &crate::md::MdOptions::plain_text(),
req_data: &req_data,
});
assert_eq!(
actual,
"|:-:|\n|__foo__|\n|-:|:-|\n|bizz|buzz|\n| bar|baz|\n|:-:|\n|__foo__|\n|-:|:-|\n|bizz|buzz|\n| bar|baz|\n|\n\n"
)
}
}

View file

@ -0,0 +1,468 @@
use {
icann_rdap_common::prelude::ObjectCommon,
std::{any::TypeId, sync::LazyLock},
};
use {
icann_rdap_common::{
check::StringCheck,
httpdata::HttpData,
response::{
Common, Event, Link, Links, NoticeOrRemark, Notices, PublicId, RdapConformance, Remarks,
},
},
reqwest::header::{
ACCESS_CONTROL_ALLOW_ORIGIN, CACHE_CONTROL, CONTENT_LENGTH, EXPIRES, HOST,
STRICT_TRANSPORT_SECURITY,
},
strum::EnumMessage,
};
use icann_rdap_common::check::{
CheckClass, CheckItem, CheckParams, Checks, GetChecks, CHECK_CLASS_LEN,
};
use super::{
checks_ul,
string::{StringListUtil, StringUtil},
table::{MultiPartTable, ToMpTable},
FromMd, MdParams, ToMd, HR,
};
impl ToMd for RdapConformance {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
md.push_str(
&format!(
"{} Conformance Claims",
params.req_data.source_host.to_title_case()
)
.to_header(5, params.options),
);
self.iter().for_each(|s| {
md.push_str(&format!(
"* {}\n",
s.0.replace('_', " ")
.to_cap_acronyms()
.to_words_title_case()
))
});
self.get_checks(CheckParams::from_md_no_parent(params))
.items
.iter()
.filter(|item| params.check_types.contains(&item.check_class))
.for_each(|item| {
md.push_str(&format!(
"* {}: {}\n",
item.check_class.to_string().to_em(params.options),
item.check
.get_message()
.expect("Check has no message. Coding error.")
))
});
md.push('\n');
md
}
}
impl ToMd for Links {
fn to_md(&self, mdparams: MdParams) -> String {
let mut md = String::new();
self.iter()
.for_each(|link| md.push_str(&link.to_md(mdparams)));
md
}
}
impl ToMd for Link {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
if let Some(title) = &self.title {
md.push_str(&format!("* {}:\n", title.replace_md_chars()));
} else {
md.push_str("* Link:\n")
};
if let Some(href) = &self.href {
md.push_str(&format!(
"* {}\n",
href.to_owned().to_inline(params.options)
));
};
if let Some(rel) = &self.rel {
md.push_str(&format!("* Relation: {}\n", rel.replace_md_chars()));
};
if let Some(media_type) = &self.media_type {
md.push_str(&format!("* Type: {}\n", media_type.replace_md_chars()));
};
if let Some(media) = &self.media {
md.push_str(&format!("* Media: {}\n", media.replace_md_chars()));
};
if let Some(value) = &self.value {
md.push_str(&format!("* Value: {}\n", value.replace_md_chars()));
};
if let Some(hreflang) = &self.hreflang {
match hreflang {
icann_rdap_common::response::HrefLang::Lang(lang) => {
md.push_str(&format!("* Language: {}\n", lang.replace_md_chars()));
}
icann_rdap_common::response::HrefLang::Langs(langs) => {
md.push_str(&format!(
"* Languages: {}",
langs.join(", ").replace_md_chars()
));
}
}
};
let checks = self.get_checks(CheckParams::from_md(params, TypeId::of::<Link>()));
md.push_str(&checks_ul(&checks, params));
md.push('\n');
md
}
}
impl ToMd for Notices {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
self.iter()
.for_each(|notice| md.push_str(&notice.0.to_md(params)));
md
}
}
impl ToMd for Remarks {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
self.iter()
.for_each(|remark| md.push_str(&remark.0.to_md(params)));
md
}
}
impl ToMd for Option<Remarks> {
fn to_md(&self, params: MdParams) -> String {
if let Some(remarks) = &self {
remarks.to_md(params)
} else {
String::new()
}
}
}
impl ToMd for NoticeOrRemark {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
if let Some(title) = &self.title {
md.push_str(&format!("{}\n", title.to_bold(params.options)));
};
if let Some(nr_type) = &self.nr_type {
md.push_str(&format!("Type: {}\n", nr_type.to_words_title_case()));
};
if let Some(description) = &self.description {
description.vec().iter().for_each(|s| {
if !s.is_whitespace_or_empty() {
md.push_str(&format!("> {}\n\n", s.trim().replace_md_chars()))
}
});
}
self.get_checks(CheckParams::from_md(params, TypeId::of::<Self>()))
.items
.iter()
.filter(|item| params.check_types.contains(&item.check_class))
.for_each(|item| {
md.push_str(&format!(
"* {}: {}\n",
&item.check_class.to_string().to_em(params.options),
item.check
.get_message()
.expect("Check has no message. Coding error.")
))
});
if let Some(links) = &self.links {
links
.iter()
.for_each(|link| md.push_str(&link.to_md(params)));
}
md.push('\n');
md
}
}
impl ToMd for Common {
fn to_md(&self, params: MdParams) -> String {
let mut md = String::new();
let not_empty = self.rdap_conformance.is_some() || self.notices.is_some();
if not_empty {
md.push('\n');
md.push_str(HR);
let header_text = format!(
"Response from {} at {}",
params.req_data.source_type,
params.req_data.source_host.to_title_case()
);
md.push_str(&header_text.to_header(params.heading_level, params.options));
};
if let Some(rdap_conformance) = &self.rdap_conformance {
md.push_str(&rdap_conformance.to_md(params));
};
if let Some(notices) = &self.notices {
md.push_str(&"Server Notices".to_header(5, params.options));
md.push_str(&notices.to_md(params));
}
if not_empty {
md.push_str(HR);
};
md
}
}
const RECEIVED: &str = "Received";
const REQUEST_URI: &str = "Request URI";
pub static NAMES: LazyLock<[String; 7]> = LazyLock::new(|| {
[
HOST.to_string(),
reqwest::header::EXPIRES.to_string(),
reqwest::header::CACHE_CONTROL.to_string(),
reqwest::header::STRICT_TRANSPORT_SECURITY.to_string(),
reqwest::header::ACCESS_CONTROL_ALLOW_ORIGIN.to_string(),
RECEIVED.to_string(),
REQUEST_URI.to_string(),
]
});
pub static NAME_LEN: LazyLock<usize> = LazyLock::new(|| {
NAMES
.iter()
.max_by_key(|x| x.to_string().len())
.map_or(8, |x| x.to_string().len())
});
impl ToMd for HttpData {
fn to_md(&self, params: MdParams) -> String {
let mut md = HR.to_string();
md.push_str(&format!(" * {:<NAME_LEN$}: {}\n", HOST, &self.host));
if let Some(request_uri) = &self.request_uri {
md.push_str(&format!(" * {:<NAME_LEN$}: {}\n", REQUEST_URI, request_uri));
}
if let Some(content_length) = &self.content_length {
md.push_str(&format!(
" * {:<NAME_LEN$}: {}\n",
CONTENT_LENGTH, content_length
));
}
if let Some(expires) = &self.expires {
md.push_str(&format!(" * {:<NAME_LEN$}: {}\n", EXPIRES, expires));
}
if let Some(cache_control) = &self.cache_control {
md.push_str(&format!(
" * {:<NAME_LEN$}: {}\n",
CACHE_CONTROL, cache_control
));
}
if let Some(strict_transport_security) = &self.strict_transport_security {
md.push_str(&format!(
" * {:<NAME_LEN$}: {}\n",
STRICT_TRANSPORT_SECURITY, strict_transport_security
));
}
if let Some(access_control_allow_origin) = &self.access_control_allow_origin {
md.push_str(&format!(
" * {:<NAME_LEN$}: {}\n",
ACCESS_CONTROL_ALLOW_ORIGIN, access_control_allow_origin
));
}
md.push_str(&format!(" * {RECEIVED:<NAME_LEN$}: {}\n", &self.received));
self.get_checks(CheckParams::from_md(params, TypeId::of::<NoticeOrRemark>()))
.items
.iter()
.filter(|item| params.check_types.contains(&item.check_class))
.for_each(|item| {
md.push_str(&format!(
"* {}: {}\n",
&item.check_class.to_string().to_em(params.options),
item.check
.get_message()
.expect("Check has no message. Coding error.")
))
});
md
}
}
impl ToMpTable for ObjectCommon {
fn add_to_mptable(&self, mut table: MultiPartTable, params: MdParams) -> MultiPartTable {
if self.status.is_some() || self.port_43.is_some() {
table = table.header_ref(&"Information");
// Status
if let Some(status) = &self.status {
let values = status.vec();
table = table.nv_ul(&"Status", values.make_list_all_title_case());
}
// Port 43
table = table.and_nv_ref(&"Whois", &self.port_43);
}
// Events
if let Some(events) = &self.events {
table = events_to_table(events, table, "Events", params);
}
// Links
if let Some(links) = &self.links {
table = links_to_table(links, table, "Links");
}
// TODO Checks
table
}
}
pub(crate) fn public_ids_to_table(
publid_ids: &[PublicId],
mut table: MultiPartTable,
) -> MultiPartTable {
for pid in publid_ids {
table = table.nv_ref(
pid.id_type.as_ref().unwrap_or(&"(not given)".to_string()),
pid.identifier
.as_ref()
.unwrap_or(&"(not given)".to_string()),
);
}
table
}
pub(crate) fn events_to_table(
events: &[Event],
mut table: MultiPartTable,
header_name: &str,
params: MdParams,
) -> MultiPartTable {
table = table.header_ref(&header_name.replace_md_chars());
for event in events {
let event_date = &event
.event_date
.to_owned()
.unwrap_or("????".to_string())
.format_date_time(params)
.unwrap_or_default();
let mut ul: Vec<&String> = vec![event_date];
if let Some(event_actor) = &event.event_actor {
ul.push(event_actor);
}
table = table.nv_ul_ref(
&event
.event_action
.as_ref()
.unwrap_or(&"action not given".to_string())
.to_owned()
.to_words_title_case(),
ul,
);
}
table
}
pub(crate) fn links_to_table(
links: &[Link],
mut table: MultiPartTable,
header_name: &str,
) -> MultiPartTable {
table = table.header_ref(&header_name.replace_md_chars());
for link in links {
if let Some(title) = &link.title {
table = table.nv_ref(&"Title", &title.trim());
};
let rel = link
.rel
.as_ref()
.unwrap_or(&"Link".to_string())
.to_title_case();
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)
};
if let Some(media) = &link.media {
ul.push(media)
};
if let Some(value) = &link.value {
ul.push(value)
};
let hreflang_s;
if let Some(hreflang) = &link.hreflang {
hreflang_s = match hreflang {
icann_rdap_common::response::HrefLang::Lang(lang) => lang.to_owned(),
icann_rdap_common::response::HrefLang::Langs(langs) => langs.join(", "),
};
ul.push(&hreflang_s)
};
table = table.nv_ul_ref(&rel, ul);
}
table
}
pub(crate) fn checks_to_table(
checks: Vec<Checks>,
mut table: MultiPartTable,
params: MdParams,
) -> MultiPartTable {
let mut filtered_checks: Vec<CheckItem> = checks
.into_iter()
.flat_map(|checks| checks.items)
.filter(|item| params.check_types.contains(&item.check_class))
.collect();
if !filtered_checks.is_empty() {
filtered_checks.sort();
filtered_checks.dedup();
table = table.header_ref(&"Checks");
// Informational
let class = CheckClass::Informational;
let ul: Vec<String> = filtered_checks
.iter()
.filter(|item| item.check_class == class)
.map(|item| item.check.get_message().unwrap_or_default().to_owned())
.collect();
table = table.nv_ul_ref(
&&class
.to_string()
.to_right_em(*CHECK_CLASS_LEN, params.options),
ul.iter().collect(),
);
// Specification Warning
let class = CheckClass::StdWarning;
let ul: Vec<String> = filtered_checks
.iter()
.filter(|item| item.check_class == class)
.map(|item| item.check.get_message().unwrap_or_default().to_owned())
.collect();
table = table.nv_ul_ref(
&class
.to_string()
.to_right_em(*CHECK_CLASS_LEN, params.options),
ul.iter().collect(),
);
// Specification Error
let class = CheckClass::StdError;
let ul: Vec<String> = filtered_checks
.iter()
.filter(|item| item.check_class == class)
.map(|item| item.check.get_message().unwrap_or_default().to_owned())
.collect();
table = table.nv_ul_ref(
&&class
.to_string()
.to_right_em(*CHECK_CLASS_LEN, params.options),
ul.iter().collect(),
);
}
table
}

View file

@ -0,0 +1,15 @@
//! Code for managing RDAP queries.
#[doc(inline)]
pub use qtype::*;
#[doc(inline)]
pub use registered_redactions::*;
#[doc(inline)]
pub use request::*;
#[doc(inline)]
pub use rr::*;
pub(crate) mod qtype;
pub(crate) mod registered_redactions;
pub(crate) mod request;
pub(crate) mod rr;

View file

@ -0,0 +1,645 @@
//! Defines the various types of RDAP queries.
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
str::FromStr,
sync::LazyLock,
};
use {
cidr::{IpCidr, Ipv4Cidr, Ipv6Cidr},
icann_rdap_common::{check::StringCheck, dns_types::DomainName},
pct_str::{PctString, URIReserved},
regex::Regex,
strum_macros::Display,
};
use crate::RdapClientError;
/// Defines the various types of RDAP lookups and searches.
#[derive(Display, Debug)]
pub enum QueryType {
#[strum(serialize = "IpV4 Address Lookup")]
IpV4Addr(Ipv4Addr),
#[strum(serialize = "IpV6 Address Lookup")]
IpV6Addr(Ipv6Addr),
#[strum(serialize = "IpV4 CIDR Lookup")]
IpV4Cidr(Ipv4Cidr),
#[strum(serialize = "IpV6 CIDR Lookup")]
IpV6Cidr(Ipv6Cidr),
#[strum(serialize = "Autonomous System Number Lookup")]
AsNumber(u32),
#[strum(serialize = "Domain Lookup")]
Domain(DomainName),
#[strum(serialize = "A-Label Domain Lookup")]
ALabel(DomainName),
#[strum(serialize = "Entity Lookup")]
Entity(String),
#[strum(serialize = "Nameserver Lookup")]
Nameserver(DomainName),
#[strum(serialize = "Entity Name Search")]
EntityNameSearch(String),
#[strum(serialize = "Entity Handle Search")]
EntityHandleSearch(String),
#[strum(serialize = "Domain Name Search")]
DomainNameSearch(String),
#[strum(serialize = "Domain Nameserver Name Search")]
DomainNsNameSearch(String),
#[strum(serialize = "Domain Nameserver IP Address Search")]
DomainNsIpSearch(IpAddr),
#[strum(serialize = "Nameserver Name Search")]
NameserverNameSearch(String),
#[strum(serialize = "Nameserver IP Address Search")]
NameserverIpSearch(IpAddr),
#[strum(serialize = "Server Help Lookup")]
Help,
#[strum(serialize = "Explicit URL")]
Url(String),
}
impl QueryType {
pub fn query_url(&self, base_url: &str) -> Result<String, RdapClientError> {
let base_url = base_url.trim_end_matches('/');
match self {
Self::IpV4Addr(value) => Ok(format!(
"{base_url}/ip/{}",
PctString::encode(value.to_string().chars(), URIReserved)
)),
Self::IpV6Addr(value) => Ok(format!(
"{base_url}/ip/{}",
PctString::encode(value.to_string().chars(), URIReserved)
)),
Self::IpV4Cidr(value) => Ok(format!(
"{base_url}/ip/{}/{}",
PctString::encode(value.first_address().to_string().chars(), URIReserved),
PctString::encode(value.network_length().to_string().chars(), URIReserved)
)),
Self::IpV6Cidr(value) => Ok(format!(
"{base_url}/ip/{}/{}",
PctString::encode(value.first_address().to_string().chars(), URIReserved),
PctString::encode(value.network_length().to_string().chars(), URIReserved)
)),
Self::AsNumber(value) => Ok(format!(
"{base_url}/autnum/{}",
PctString::encode(value.to_string().chars(), URIReserved)
)),
Self::Domain(value) => Ok(format!(
"{base_url}/domain/{}",
PctString::encode(value.trim_leading_dot().chars(), URIReserved)
)),
Self::ALabel(value) => Ok(format!(
"{base_url}/domain/{}",
PctString::encode(value.to_ascii().chars(), URIReserved),
)),
Self::Entity(value) => Ok(format!(
"{base_url}/entity/{}",
PctString::encode(value.chars(), URIReserved)
)),
Self::Nameserver(value) => Ok(format!(
"{base_url}/nameserver/{}",
PctString::encode(value.to_ascii().chars(), URIReserved)
)),
Self::EntityNameSearch(value) => search_query(value, "entities?fn", base_url),
Self::EntityHandleSearch(value) => search_query(value, "entities?handle", base_url),
Self::DomainNameSearch(value) => search_query(value, "domains?name", base_url),
Self::DomainNsNameSearch(value) => search_query(value, "domains?nsLdhName", base_url),
Self::DomainNsIpSearch(value) => {
search_query(&value.to_string(), "domains?nsIp", base_url)
}
Self::NameserverNameSearch(value) => search_query(value, "nameservers?name", base_url),
Self::NameserverIpSearch(value) => {
search_query(&value.to_string(), "nameservers?ip", base_url)
}
Self::Help => Ok(format!("{base_url}/help")),
Self::Url(url) => Ok(url.to_owned()),
}
}
pub fn domain(domain_name: &str) -> Result<QueryType, RdapClientError> {
Ok(Self::Domain(DomainName::from_str(domain_name)?))
}
pub fn alabel(alabel: &str) -> Result<QueryType, RdapClientError> {
Ok(Self::ALabel(DomainName::from_str(alabel)?))
}
pub fn ns(nameserver: &str) -> Result<QueryType, RdapClientError> {
Ok(Self::Nameserver(DomainName::from_str(nameserver)?))
}
pub fn autnum(autnum: &str) -> Result<QueryType, RdapClientError> {
let value = autnum
.trim_start_matches(|c| -> bool { matches!(c, 'a' | 'A' | 's' | 'S') })
.parse::<u32>()
.map_err(|_e| RdapClientError::InvalidQueryValue)?;
Ok(Self::AsNumber(value))
}
pub fn ipv4(ip: &str) -> Result<QueryType, RdapClientError> {
let value = Ipv4Addr::from_str(ip).map_err(|_e| RdapClientError::InvalidQueryValue)?;
Ok(Self::IpV4Addr(value))
}
pub fn ipv6(ip: &str) -> Result<QueryType, RdapClientError> {
let value = Ipv6Addr::from_str(ip).map_err(|_e| RdapClientError::InvalidQueryValue)?;
Ok(Self::IpV6Addr(value))
}
pub fn ipv4cidr(cidr: &str) -> Result<QueryType, RdapClientError> {
let value = cidr::parsers::parse_cidr_ignore_hostbits::<IpCidr, _>(
cidr,
cidr::parsers::parse_loose_ip,
)
.map_err(|_e| RdapClientError::InvalidQueryValue)?;
if let IpCidr::V4(v4) = value {
Ok(Self::IpV4Cidr(v4))
} else {
Err(RdapClientError::AmbiquousQueryType)
}
}
pub fn ipv6cidr(cidr: &str) -> Result<QueryType, RdapClientError> {
let value = cidr::parsers::parse_cidr_ignore_hostbits::<IpCidr, _>(
cidr,
cidr::parsers::parse_loose_ip,
)
.map_err(|_e| RdapClientError::InvalidQueryValue)?;
if let IpCidr::V6(v6) = value {
Ok(Self::IpV6Cidr(v6))
} else {
Err(RdapClientError::AmbiquousQueryType)
}
}
pub fn domain_ns_ip_search(ip: &str) -> Result<QueryType, RdapClientError> {
let value = IpAddr::from_str(ip).map_err(|_e| RdapClientError::InvalidQueryValue)?;
Ok(Self::DomainNsIpSearch(value))
}
pub fn ns_ip_search(ip: &str) -> Result<QueryType, RdapClientError> {
let value = IpAddr::from_str(ip).map_err(|_e| RdapClientError::InvalidQueryValue)?;
Ok(Self::NameserverIpSearch(value))
}
}
fn search_query(value: &str, path_query: &str, base_url: &str) -> Result<String, RdapClientError> {
Ok(format!(
"{base_url}/{path_query}={}",
PctString::encode(value.chars(), URIReserved)
))
}
impl FromStr for QueryType {
type Err = RdapClientError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// if it looks like a HTTP(S) url
if s.starts_with("http://") || s.starts_with("https://") {
return Ok(Self::Url(s.to_owned()));
}
// if looks like an autnum
let autnum = s.trim_start_matches(|c| -> bool { matches!(c, 'a' | 'A' | 's' | 'S') });
if u32::from_str(autnum).is_ok() {
return Self::autnum(s);
}
// If it's an IP address
if let Ok(ip_addr) = IpAddr::from_str(s) {
if ip_addr.is_ipv4() {
return Self::ipv4(s);
} else {
return Self::ipv6(s);
}
}
// if it is a cidr
if let Ok(ip_cidr) = parse_cidr(s) {
return Ok(match ip_cidr {
IpCidr::V4(cidr) => Self::IpV4Cidr(cidr),
IpCidr::V6(cidr) => Self::IpV6Cidr(cidr),
});
}
// if it looks like a domain name
if is_domain_name(s) {
return if is_nameserver(s) {
Self::ns(s)
} else {
Self::domain(s)
};
}
// if it is just one word
if !s.contains(|c: char| c.is_whitespace() || matches!(c, '.' | ',' | '"')) {
return Ok(Self::Entity(s.to_owned()));
}
// The query type cannot be deteremined.
Err(RdapClientError::AmbiquousQueryType)
}
}
fn parse_cidr(s: &str) -> Result<IpCidr, RdapClientError> {
let Some((prefix, suffix)) = s.split_once('/') else {
return Err(RdapClientError::InvalidQueryValue);
};
if prefix.chars().all(|c: char| c.is_ascii_alphanumeric()) {
let cidr = cidr::parsers::parse_short_ip_address_as_cidr(prefix)
.map_err(|_e| RdapClientError::InvalidQueryValue)?;
IpCidr::new(
cidr.first_address(),
suffix
.parse::<u8>()
.map_err(|_e| RdapClientError::InvalidQueryValue)?,
)
.map_err(|_e| RdapClientError::InvalidQueryValue)
} else {
cidr::parsers::parse_cidr_ignore_hostbits::<IpCidr, _>(s, cidr::parsers::parse_loose_ip)
.map_err(|_e| RdapClientError::InvalidQueryValue)
}
}
fn is_ldh_domain(text: &str) -> bool {
static LDH_DOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?i)(\.?[a-zA-Z0-9-]+)*\.[a-zA-Z0-9-]+\.?$").unwrap());
LDH_DOMAIN_RE.is_match(text)
}
fn is_domain_name(text: &str) -> bool {
text.contains('.') && text.is_unicode_domain_name()
}
fn is_nameserver(text: &str) -> bool {
static NS_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?i)(ns)[a-zA-Z0-9-]*\.[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+\.?$").unwrap()
});
NS_RE.is_match(text)
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use rstest::rstest;
use super::*;
#[test]
fn test_ipv4_query_type_from_str() {
// GIVEN
let s = "129.129.1.1";
// WHEN
let q = QueryType::from_str(s);
// THEN
assert!(matches!(q.unwrap(), QueryType::IpV4Addr(_)))
}
#[test]
fn test_ipv6_query_type_from_str() {
// GIVEN
let s = "2001::1";
// WHEN
let q = QueryType::from_str(s);
// THEN
assert!(matches!(q.unwrap(), QueryType::IpV6Addr(_)))
}
#[test]
fn test_ipv4_cidr_query_type_from_str() {
// GIVEN
let s = "129.129.1.1/8";
// WHEN
let q = QueryType::from_str(s);
// THEN
assert!(matches!(q.unwrap(), QueryType::IpV4Cidr(_)))
}
#[test]
fn test_ipv6_cidr_query_type_from_str() {
// GIVEN
let s = "2001::1/20";
// WHEN
let q = QueryType::from_str(s);
// THEN
assert!(matches!(q.unwrap(), QueryType::IpV6Cidr(_)))
}
#[test]
fn test_number_query_type_from_str() {
// GIVEN
let s = "16509";
// WHEN
let q = QueryType::from_str(s);
// THEN
assert!(matches!(q.unwrap(), QueryType::AsNumber(_)))
}
#[test]
fn test_as_followed_by_number_query_type_from_str() {
// GIVEN
let s = "as16509";
// WHEN
let q = QueryType::from_str(s);
// THEN
assert!(matches!(q.unwrap(), QueryType::AsNumber(_)))
}
#[rstest]
#[case("example.com")]
#[case("foo.example.com")]
#[case("snark.fail")]
#[case("ns.fail")]
#[case(".com")]
fn test_domain_name_query_type_from_str(#[case] input: &str) {
// GIVEN case input
// WHEN
let q = QueryType::from_str(input);
// THEN
assert!(matches!(q.unwrap(), QueryType::Domain(_)))
}
#[rstest]
#[case("ns.example.com")]
#[case("ns1.example.com")]
#[case("NS1.example.com")]
fn test_name_server_query_type_from_str(#[case] input: &str) {
// GIVEN case input
// WHEN
let q = QueryType::from_str(input);
// THEN
assert!(matches!(q.unwrap(), QueryType::Nameserver(_)))
}
#[test]
fn test_single_word_query_type_from_str() {
// GIVEN
let s = "foo";
// WHEN
let q = QueryType::from_str(s);
// THEN
let q = q.unwrap();
assert!(matches!(q, QueryType::Entity(_)))
}
#[rstest]
#[case("https://example.com")]
#[case("http://foo.example.com")]
fn test_url_query_type_from_str(#[case] input: &str) {
// GIVEN case input
// WHEN
let q = QueryType::from_str(input);
// THEN
assert!(matches!(q.unwrap(), QueryType::Url(_)))
}
#[rstest]
#[case("ns.foo_bar.com")]
#[case("ns.foo bar.com")]
fn test_bad_input_query_type_from_str(#[case] input: &str) {
// GIVEN case input
// WHEN
let q = QueryType::from_str(input);
// THEN
assert!(q.is_err());
}
#[rstest]
#[case("10.0.0.0/8", "10.0.0.0/8")]
#[case("10.0.0/8", "10.0.0.0/8")]
#[case("10.0/8", "10.0.0.0/8")]
#[case("10/8", "10.0.0.0/8")]
#[case("10.0.0.0/24", "10.0.0.0/24")]
#[case("10.0.0/24", "10.0.0.0/24")]
#[case("10.0/24", "10.0.0.0/24")]
#[case("10/24", "10.0.0.0/24")]
#[case("129.129.1.1/8", "129.0.0.0/8")]
#[case("2001::1/32", "2001::/32")]
fn test_cidr_parse_cidr(#[case] actual: &str, #[case] expected: &str) {
// GIVEN case input
// WHEN
let q = parse_cidr(actual);
// THEN
assert_eq!(q.unwrap().to_string(), expected)
}
#[test]
fn test_ipv4addr_query_url() {
// GIVEN ipv4 addr query
let q = QueryType::from_str("199.1.1.1").expect("query type");
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/ip/199.1.1.1")
}
#[test]
fn test_ipv6addr_query_url() {
// GIVEN
let q = QueryType::from_str("2000::1").expect("query type");
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/ip/2000%3A%3A1")
}
#[test]
fn test_ipv4cidr_query_url() {
// GIVEN
let q = QueryType::from_str("199.1.1.1/16").expect("query type");
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/ip/199.1.0.0/16")
}
#[test]
fn test_ipv6cidr_query_url() {
// GIVEN
let q = QueryType::from_str("2000::1/16").expect("query type");
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/ip/2000%3A%3A/16")
}
#[test]
fn test_autnum_query_url() {
// GIVEN
let q = QueryType::from_str("as16509").expect("query type");
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/autnum/16509")
}
#[test]
fn test_domain_query_url() {
// GIVEN
let q = QueryType::from_str("example.com").expect("query type");
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/domain/example.com")
}
#[test]
fn test_ns_query_url() {
// GIVEN
let q = QueryType::from_str("ns.example.com").expect("query type");
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/nameserver/ns.example.com")
}
#[test]
fn test_entity_query_url() {
// GIVEN
let q = QueryType::from_str("foo").expect("query type");
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/entity/foo")
}
#[test]
fn test_entity_name_search_query_url() {
// GIVEN
let q = QueryType::EntityNameSearch("foo".to_string());
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/entities?fn=foo")
}
#[test]
fn test_entity_handle_search_query_url() {
// GIVEN
let q = QueryType::EntityHandleSearch("foo".to_string());
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/entities?handle=foo")
}
#[test]
fn test_domain_name_search_query_url() {
// GIVEN
let q = QueryType::DomainNameSearch("foo".to_string());
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/domains?name=foo")
}
#[test]
fn test_domain_ns_name_search_query_url() {
// GIVEN
let q = QueryType::DomainNsNameSearch("foo".to_string());
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/domains?nsLdhName=foo")
}
#[test]
fn test_domain_ns_ip_search_query_url() {
// GIVEN
let q = QueryType::DomainNsIpSearch(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)));
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/domains?nsIp=1.1.1.1")
}
#[test]
fn test_ns_name_search_query_url() {
// GIVEN
let q = QueryType::NameserverNameSearch("foo".to_string());
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/nameservers?name=foo")
}
#[test]
fn test_ns_ip_search_query_url() {
// GIVEN
let q = QueryType::NameserverIpSearch(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)));
// WHEN
let actual = q.query_url("https://example.com").expect("query url");
// THEN
assert_eq!(actual, "https://example.com/nameservers?ip=1.1.1.1")
}
}

View file

@ -0,0 +1,636 @@
//! Determines of an RFC 9537 registered redaction is present.
use {
icann_rdap_common::response::{Entity, EntityRole, RdapResponse},
strum_macros::{Display, EnumString},
};
/// Redacted types in the IANA registry
#[derive(Debug, PartialEq, Eq, EnumString, Display)]
pub enum RedactedName {
#[strum(serialize = "Registry Domain ID")]
RegistryDomainId,
#[strum(serialize = "Registry Registrant ID")]
RegistryRegistrantId,
#[strum(serialize = "Registrant Name")]
RegistrantName,
#[strum(serialize = "Registrant Organization")]
RegistrantOrganization,
#[strum(serialize = "Registrant Street")]
RegistrantStreet,
#[strum(serialize = "Registrant City")]
RegistrantCity,
#[strum(serialize = "Registrant Postal Code")]
RegistrantPostalCode,
#[strum(serialize = "Registrant Phone")]
RegistrantPhone,
#[strum(serialize = "Registrant Phone Ext")]
RegistrantPhoneExt,
#[strum(serialize = "Registrant Fax")]
RegistrantFax,
#[strum(serialize = "Registrant Fax Ext")]
RegistrantFaxExt,
#[strum(serialize = "Registrant Email")]
RegistrantEmail,
#[strum(serialize = "Registry Tech ID")]
RegistryTechId,
#[strum(serialize = "Tech Name")]
TechName,
#[strum(serialize = "Tech Phone")]
TechPhone,
#[strum(serialize = "Tech Phone Ext")]
TechPhoneExt,
#[strum(serialize = "Tech Email")]
TechEmail,
}
/// This function looks at the RDAP response to see if a
/// redaction is present where the type of redaction is registered
/// with the IANA.
///
/// * rdap_response - a reference to the RDAP response.
/// * redaction_type - a reference to the string registered in the IANA.
pub fn is_redaction_registered(
rdap_response: &RdapResponse,
redaction_type: &RedactedName,
) -> bool {
let object_common = match rdap_response {
RdapResponse::Entity(e) => Some(&e.object_common.redacted),
RdapResponse::Domain(d) => Some(&d.object_common.redacted),
RdapResponse::Nameserver(s) => Some(&s.object_common.redacted),
RdapResponse::Autnum(a) => Some(&a.object_common.redacted),
RdapResponse::Network(n) => Some(&n.object_common.redacted),
_ => None,
};
if let Some(Some(redacted_vec)) = object_common {
redacted_vec.iter().any(|r| {
if let Some(r_type) = &r.name.type_field {
r_type.eq_ignore_ascii_case(&redaction_type.to_string())
} else {
false
}
})
} else {
false
}
}
/// This function takes a set of [RedactedName]s instead of just one,
/// and runs them through [is_redaction_registered].
pub fn are_redactions_registered(
rdap_response: &RdapResponse,
redaction_types: &[&RedactedName],
) -> bool {
redaction_types
.iter()
.any(|rn| is_redaction_registered(rdap_response, rn))
}
/// This function substitutes redaction_text if [is_redaction_registered] returns true.
pub fn text_or_registered_redaction(
rdap_response: &RdapResponse,
redaction_type: &RedactedName,
text: &Option<String>,
redaction_text: &str,
) -> Option<String> {
if is_redaction_registered(rdap_response, redaction_type) {
Some(redaction_text.to_string())
} else {
text.clone()
}
}
/// This function checks that an entity has a certain role, and if so then
/// checks of the redaction is registered for IANA.
///
/// * rdap_response - a reference to the RDAP response.
/// * redaction_type - a reference to the string registered in the IANA.
/// * entity - a reference to the entity to check
/// * role - the role of the entity
pub fn is_redaction_registered_for_role(
rdap_response: &RdapResponse,
redaction_type: &RedactedName,
entity: &Entity,
entity_role: &EntityRole,
) -> bool {
let roles = entity.roles();
if roles
.iter()
.any(|r| r.eq_ignore_ascii_case(&entity_role.to_string()))
{
return is_redaction_registered(rdap_response, redaction_type);
}
false
}
/// Same as [is_redaction_registered_for_role] but takes an array of [EntityRole] references.
pub fn are_redactions_registered_for_roles(
rdap_response: &RdapResponse,
redaction_type: &[&RedactedName],
entity: &Entity,
entity_roles: &[&EntityRole],
) -> bool {
let roles = entity.roles();
if roles.iter().any(|r| {
entity_roles
.iter()
.any(|er| r.eq_ignore_ascii_case(&er.to_string()))
}) {
return are_redactions_registered(rdap_response, redaction_type);
}
false
}
/// This function substitutes redaction_text if [is_redaction_registered_for_role] return true.
pub fn text_or_registered_redaction_for_role(
rdap_response: &RdapResponse,
redaction_type: &RedactedName,
entity: &Entity,
entity_role: &EntityRole,
text: &Option<String>,
redaction_text: &str,
) -> Option<String> {
if is_redaction_registered_for_role(rdap_response, redaction_type, entity, entity_role) {
Some(redaction_text.to_string())
} else {
text.clone()
}
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use icann_rdap_common::{
prelude::ToResponse,
response::{
redacted::{Name, Redacted},
Domain,
},
};
use super::*;
#[test]
fn GIVEN_redaction_type_WHEN_search_for_type_THEN_true() {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::TechEmail.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
}])
.build();
let rdap = domain.to_response();
// WHEN
let actual = is_redaction_registered(&rdap, &RedactedName::TechEmail);
// THEN
assert!(actual);
}
#[test]
fn GIVEN_redaction_type_WHEN_get_text_for_type_THEN_redacted_text_returned() {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::TechEmail.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
}])
.build();
let rdap = domain.to_response();
// WHEN
let actual = text_or_registered_redaction(
&rdap,
&RedactedName::TechEmail,
&Some("not_redacted".to_string()),
"redacted",
);
// THEN
assert_eq!(actual, Some("redacted".to_string()));
}
#[test]
fn GIVEN_multiple_redaction_type_WHEN_search_for_one_of_the_types_THEN_true() {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::TechEmail.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::RegistryRegistrantId.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
])
.build();
let rdap = domain.to_response();
// WHEN
let actual = is_redaction_registered(&rdap, &RedactedName::TechEmail);
// THEN
assert!(actual);
}
#[test]
fn GIVEN_multiple_redaction_type_WHEN_search_for_multiple_that_some_exist_THEN_true() {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::TechEmail.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::RegistryRegistrantId.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
])
.build();
let rdap = domain.to_response();
// WHEN
let actual = are_redactions_registered(
&rdap,
&[&RedactedName::TechEmail, &RedactedName::RegistrantName],
);
// THEN
assert!(actual);
}
#[test]
fn GIVEN_multiple_redaction_type_WHEN_search_for_multiple_that_not_exist_THEN_false() {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::TechEmail.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::RegistryRegistrantId.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
])
.build();
let rdap = domain.to_response();
// WHEN
let actual = are_redactions_registered(
&rdap,
&[
&RedactedName::RegistrantPhone,
&RedactedName::RegistrantName,
],
);
// THEN
assert!(!actual);
}
#[test]
fn GIVEN_no_redactions_WHEN_search_for_type_THEN_false() {
// GIVEN
let domain = Domain::builder().ldh_name("example.com").build();
let rdap = domain.to_response();
// WHEN
let actual = is_redaction_registered(&rdap, &RedactedName::TechEmail);
// THEN
assert!(!actual);
}
#[test]
fn GIVEN_redaction_type_WHEN_search_for_wrong_type_THEN_false() {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::RegistryRegistrantId.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
}])
.build();
let rdap = domain.to_response();
// WHEN
let actual = is_redaction_registered(&rdap, &RedactedName::TechEmail);
// THEN
assert!(!actual);
}
#[test]
fn GIVEN_entity_and_redaction_type_WHEN_search_for_type_on_entity_with_role_THEN_true() {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::TechEmail.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
}])
.build();
let rdap = domain.to_response();
let role = EntityRole::Technical.to_string();
let entity = Entity::builder()
.handle("foo_bar")
.role(role.clone())
.build();
// WHEN
let actual = is_redaction_registered_for_role(
&rdap,
&RedactedName::TechEmail,
&entity,
&EntityRole::Technical,
);
// THEN
assert!(actual);
}
#[test]
fn GIVEN_entity_and_multiple_redaction_WHEN_search_for_multipe_type_on_entity_with_roles_THEN_true(
) {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::TechEmail.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::RegistryRegistrantId.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
])
.build();
let rdap = domain.to_response();
let role = EntityRole::Technical.to_string();
let entity = Entity::builder()
.handle("foo_bar")
.role(role.clone())
.build();
// WHEN
let actual = are_redactions_registered_for_roles(
&rdap,
&[&RedactedName::TechEmail, &RedactedName::TechPhoneExt],
&entity,
&[&EntityRole::Technical, &EntityRole::Abuse],
);
// THEN
assert!(actual);
}
#[test]
fn GIVEN_entity_and_multiple_redaction_WHEN_search_for_not_exist_type_on_entity_with_roles_THEN_false(
) {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::TechEmail.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::RegistryRegistrantId.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
])
.build();
let rdap = domain.to_response();
let role = EntityRole::Technical.to_string();
let entity = Entity::builder()
.handle("foo_bar")
.role(role.clone())
.build();
// WHEN
let actual = are_redactions_registered_for_roles(
&rdap,
&[&RedactedName::TechPhone, &RedactedName::TechPhoneExt],
&entity,
&[&EntityRole::Technical, &EntityRole::Abuse],
);
// THEN
assert!(!actual);
}
#[test]
fn GIVEN_entity_and_multiple_redaction_WHEN_search_for_type_on_entity_with_other_rolesroles_THEN_false(
) {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::TechEmail.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::RegistryRegistrantId.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
},
])
.build();
let rdap = domain.to_response();
let role = EntityRole::Technical.to_string();
let entity = Entity::builder()
.handle("foo_bar")
.role(role.clone())
.build();
// WHEN
let actual = are_redactions_registered_for_roles(
&rdap,
&[&RedactedName::TechEmail, &RedactedName::TechPhoneExt],
&entity,
&[&EntityRole::Billing, &EntityRole::Abuse],
);
// THEN
assert!(!actual);
}
#[test]
fn GIVEN_entity_and_redaction_type_WHEN_get_text_for_type_on_entity_with_role_THEN_redaction_text_returned(
) {
// GIVEN
let domain = Domain::builder()
.ldh_name("example.com")
.redacted(vec![Redacted {
name: Name {
description: None,
type_field: Some(RedactedName::TechEmail.to_string()),
},
reason: None,
pre_path: None,
post_path: None,
path_lang: None,
replacement_path: None,
method: None,
}])
.build();
let rdap = domain.to_response();
let role = EntityRole::Technical.to_string();
let entity = Entity::builder()
.handle("foo_bar")
.role(role.clone())
.build();
// WHEN
let actual = text_or_registered_redaction_for_role(
&rdap,
&RedactedName::TechEmail,
&entity,
&EntityRole::Technical,
&Some("not_redacted".to_string()),
"redacted",
);
// THEN
assert_eq!(actual, Some("redacted".to_string()));
}
}

View file

@ -0,0 +1,177 @@
//! Functions to make RDAP requests.
use {
icann_rdap_common::{httpdata::HttpData, iana::IanaRegistryType, response::RdapResponse},
serde::{Deserialize, Serialize},
serde_json::Value,
};
use crate::{
http::{wrapped_request, Client},
iana::bootstrap::{qtype_to_bootstrap_url, BootstrapStore},
RdapClientError,
};
use super::qtype::QueryType;
/// Makes an RDAP request with a full RDAP URL.
///
/// This function takes the following parameters:
/// * url - a string reference of the URL
/// * client - a reference to a [reqwest::Client].
///
/// ```no_run
/// use icann_rdap_client::prelude::*;
/// use std::str::FromStr;
/// use tokio::main;
///
/// #[tokio::main]
/// async fn main() -> Result<(), RdapClientError> {
///
/// // create a client (from icann-rdap-common)
/// let config = ClientConfig::default();
/// let client = create_client(&config)?;
///
/// // issue the RDAP query
/// let response =
/// rdap_url_request(
/// "https://rdap-bootstrap.arin.net/bootstrap/ip/192.168.0.1",
/// &client,
/// ).await?;
///
/// Ok(())
/// }
/// ```
pub async fn rdap_url_request(url: &str, client: &Client) -> Result<ResponseData, RdapClientError> {
let wrapped_response = wrapped_request(url, client).await?;
// for convenience purposes
let text = wrapped_response.text;
let http_data = wrapped_response.http_data;
let json: Result<Value, serde_json::Error> = serde_json::from_str(&text);
if let Ok(rdap_json) = json {
let rdap = RdapResponse::try_from(rdap_json)?;
Ok(ResponseData {
http_data,
rdap_type: rdap.to_string(),
rdap,
})
} else {
Err(RdapClientError::ParsingError(Box::new(
crate::ParsingErrorInfo {
text,
http_data,
error: json.err().unwrap(),
},
)))
}
}
/// Makes an RDAP request with a base URL.
///
/// This function takes the following parameters:
/// * base_url - a string reference of the base URL
/// * query_type - a reference to the RDAP query.
/// * client - a reference to a [reqwest::Client].
///
/// ```no_run
/// use icann_rdap_client::prelude::*;
/// use std::str::FromStr;
/// use tokio::main;
///
/// #[tokio::main]
/// async fn main() -> Result<(), RdapClientError> {
///
/// // create a query
/// let query = QueryType::from_str("192.168.0.1")?;
/// // or
/// let query = QueryType::from_str("icann.org")?;
///
/// // create a client (from icann-rdap-common)
/// let config = ClientConfig::default();
/// let client = create_client(&config)?;
///
/// // issue the RDAP query
/// let response =
/// rdap_request(
/// "https://rdap-bootstrap.arin.net/bootstrap",
/// &query,
/// &client,
/// ).await?;
///
/// Ok(())
/// }
/// ```
pub async fn rdap_request(
base_url: &str,
query_type: &QueryType,
client: &Client,
) -> Result<ResponseData, RdapClientError> {
let url = query_type.query_url(base_url)?;
rdap_url_request(&url, client).await
}
/// Makes an RDAP request using bootstrapping.
///
/// This function takes the following parameters:
/// * query_type - a reference to the RDAP query.
/// * client - a reference to a [reqwest::Client].
/// * store - a reference to a [BootstrapStore].
/// * callback - a closure that is called when an IANA registry is fetched.
///
/// The [BootstrapStore] is responsible for holding IANA RDAP bootstrap registries.
/// It will be populated with IANA registries as needed. Ideally, the calling code
/// would be kept it in the same scope as `client`. When using the [crate::iana::bootstrap::MemoryBootstrapStore],
/// creating a new store for each request will result it fetching the appropriate IANA
/// registry with each request which is most likely not the desired behavior.
///
/// ```no_run
/// use icann_rdap_client::prelude::*;
/// use std::str::FromStr;
/// use tokio::main;
///
/// #[tokio::main]
/// async fn main() -> Result<(), RdapClientError> {
///
/// // create a query
/// let query = QueryType::from_str("192.168.0.1")?;
/// // or
/// let query = QueryType::from_str("icann.org")?;
///
/// // create a client (from icann-rdap-common)
/// let config = ClientConfig::default();
/// let client = create_client(&config)?;
/// let store = MemoryBootstrapStore::new();
///
/// // issue the RDAP query
/// let response =
/// rdap_bootstrapped_request(
/// &query,
/// &client,
/// &store,
/// |reg| eprintln!("fetching {reg:?}")
/// ).await?;
///
/// Ok(())
/// }
/// ```
pub async fn rdap_bootstrapped_request<F>(
query_type: &QueryType,
client: &Client,
store: &dyn BootstrapStore,
callback: F,
) -> Result<ResponseData, RdapClientError>
where
F: FnOnce(&IanaRegistryType),
{
let base_url = qtype_to_bootstrap_url(client, store, query_type, callback).await?;
rdap_request(&base_url, query_type, client).await
}
/// The data returned from an rdap request.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ResponseData {
pub rdap: RdapResponse,
pub rdap_type: String,
pub http_data: HttpData,
}

View file

@ -0,0 +1,60 @@
//! Structures that describe a request/response.
use {
icann_rdap_common::check::Checks,
serde::{Deserialize, Serialize},
strum_macros::Display,
};
use crate::rdap::request::ResponseData;
/// Types of RDAP servers.
#[derive(Serialize, Deserialize, Display, Clone, Copy)]
pub enum SourceType {
#[strum(serialize = "Domain Registry")]
DomainRegistry,
#[strum(serialize = "Domain Registrar")]
DomainRegistrar,
#[strum(serialize = "Regional Internet Registry")]
RegionalInternetRegistry,
#[strum(serialize = "Local Internet Registry")]
LocalInternetRegistry,
#[strum(serialize = "Uncategorized Registry")]
UncategorizedRegistry,
}
/// Represents meta data about the request.
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct RequestData<'a> {
/// The request number. That is, request 1, request 2, etc...
pub req_number: usize,
/// A human-friendly name to identify the source of the information.
/// Examples might be "registry", "registrar", etc...
pub source_host: &'a str,
/// Represents the type of source.
pub source_type: SourceType,
}
/// Structure for serializing request and response data.
#[derive(Clone, Serialize)]
pub struct RequestResponse<'a> {
pub req_data: &'a RequestData<'a>,
pub res_data: &'a ResponseData,
pub checks: Checks,
}
/// The primary purpose for this struct is to allow deserialization for testing.
/// If somebody can help get #[serde(borrow)] to work for the non-owned version,
/// that would be awesome.
#[derive(Clone, Deserialize)]
pub struct RequestResponseOwned<'a> {
#[serde(borrow)]
pub req_data: RequestData<'a>,
pub res_data: ResponseData,
pub checks: Checks,
}
/// A [Vec] of [RequestResponse].
pub type RequestResponses<'a> = Vec<RequestResponse<'a>>;

View file

@ -0,0 +1,259 @@
{
"rdapConformance": [
"rdap_level_0",
"redacted"
],
"objectClassName": "domain",
"ldhName": "example-1.net",
"secureDNS": {
"delegationSigned": false
},
"notices": [
{
"title": "Terms of Use",
"description": [
"Service subject to Terms of Use."
],
"links": [
{
"rel": "self",
"href": "https://www.example.com/terms-of-use",
"type": "text/html",
"value": "https://www.example.com/terms-of-use"
}
]
}
],
"nameservers": [
{
"objectClassName": "nameserver",
"ldhName": "ns1.example.com"
},
{
"objectClassName": "nameserver",
"ldhName": "ns2.example.com"
}
],
"entities": [
{
"objectClassName": "entity",
"handle": "123",
"roles": [
"registrar"
],
"publicIds": [
{
"type": "IANA Registrar ID",
"identifier": "1"
}
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Example Registrar Inc."
],
[
"adr",
{},
"text",
[
"",
"Suite 100",
"123 Example Dr.",
"Dulles",
"VA",
"20166-6503",
"US"
]
],
[
"email",
{},
"text",
"contact@organization.example"
],
[
"tel",
{
"type": "voice"
},
"uri",
"tel:+1.7035555555"
],
[
"tel",
{
"type": "fax"
},
"uri",
"tel:+1.7035555556"
]
]
],
"entities": [
{
"objectClassName": "entity",
"roles": [
"abuse"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Abuse Contact"
],
[
"email",
{},
"text",
"abuse@organization.example"
],
[
"tel",
{
"type": "voice"
},
"uri",
"tel:+1.7035555555"
]
]
]
}
]
},
{
"objectClassName": "entity",
"handle": "XXXX",
"roles": [
"registrant"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"*REDACTED*"
],
[
"adr",
{},
"text",
[
"",
"",
"",
"",
"QC",
"",
"Canada"
]
]
]
]
},
{
"objectClassName": "entity",
"handle": "YYYY",
"roles": [
"technical"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
""
],
[
"org",
{},
"text",
"Example Inc."
],
[
"adr",
{},
"text",
[
"",
"Suite 1234",
"4321 Rue Somewhere",
"Quebec",
"QC",
"G1V 2M2",
"Canada"
]
]
]
]
}
],
"events": [
{
"eventAction": "registration",
"eventDate": "1997-06-03T00:00:00Z"
},
{
"eventAction": "last changed",
"eventDate": "2020-05-28T01:35:00Z"
},
{
"eventAction": "expiration",
"eventDate": "2021-06-03T04:00:00Z"
}
],
"status": [
"server delete prohibited",
"server update prohibited",
"server transfer prohibited",
"client transfer prohibited"
],
"redacted": [
{
"name": {
"description": "Registrant Name"
},
"postPath": "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='fn')][3]",
"pathLang": "jsonpath",
"method": "emptyValue",
"reason": {
"description": "Server policy"
}
}
]
}

View file

@ -0,0 +1,240 @@
{
"rdapConformance": [
"rdap_level_0",
"redacted"
],
"objectClassName": "domain",
"ldhName": "example-1.net",
"secureDNS": { "delegationSigned": false },
"notices": [
{
"title": "Terms of Use",
"description": [ "Service subject to Terms of Use." ],
"links": [
{
"rel": "self",
"href": "https://www.example.com/terms-of-use",
"type": "text/html",
"value": "https://www.example.com/terms-of-use"
}
]
}
],
"nameservers": [
{
"objectClassName": "nameserver", "ldhName": "ns1.example.com" },
{
"objectClassName": "nameserver", "ldhName": "ns2.example.com" }
],
"entities": [
{
"objectClassName": "entity",
"handle": "123",
"roles": [ "registrar" ],
"publicIds": [
{ "type": "IANA Registrar ID", "identifier": "1" }
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Example Registrar Inc."
],
[
"adr",
{},
"text",
[
"",
"Suite 100",
"123 Example Dr.",
"Dulles",
"VA",
"20166-6503",
"US"
]
],
[
"email",
{},
"text",
"contact@organization.example"
],
[
"tel",
{
"type": "voice"
},
"uri",
"tel:+1.7035555555"
],
[
"tel",
{
"type": "fax"
},
"uri",
"tel:+1.7035555556"
]
]
],
"entities": [
{
"objectClassName": "entity",
"roles": [
"abuse"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Abuse Contact"
],
[
"email",
{},
"text",
"abuse@organization.example"
],
[
"tel",
{
"type": "voice"
},
"uri",
"tel:+1.7035555555"
]
]
]
}
]
},
{
"objectClassName": "entity",
"handle": "XXXX",
"roles": [
"registrant"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
""
],
[
"adr",
{},
"text",
[
"",
"",
"",
"",
"QC",
"",
"Canada"
]
]
]
]
},
{
"objectClassName": "entity",
"handle": "YYYY",
"roles": [
"technical"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
""
],
[
"org",
{},
"text",
"Example Inc."
],
[
"adr",
{},
"text",
[
"",
"Suite 1234",
"4321 Rue Somewhere",
"Quebec",
"QC",
"G1V 2M2",
"Canada"
]
]
]
]
}
],
"events": [
{
"eventAction": "registration", "eventDate": "1997-06-03T00:00:00Z"
},
{
"eventAction": "last changed", "eventDate": "2020-05-28T01:35:00Z"
},
{
"eventAction": "expiration", "eventDate": "2021-06-03T04:00:00Z"
}
],
"status": [
"server delete prohibited", "server update prohibited", "server transfer prohibited", "client transfer prohibited"
],
"redacted": [
{
"name": {
"description": "Registrant Name"
},
"postPath": "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='fn')][3]",
"pathLang": "jsonpath",
"method": "emptyValue",
"reason": {
"description": "Server policy"
}
}
]
}

View file

@ -0,0 +1,302 @@
{
"rdapConformance": [
"rdap_level_0",
"redacted"
],
"objectClassName": "domain",
"ldhName": "example-3.net",
"secureDNS": {
"delegationSigned": false
},
"notices": [
{
"title": "Terms of Use",
"description": [
"Service subject to Terms of Use."
],
"links": [
{
"rel": "self",
"href": "https://www.example.com/terms-of-use",
"type": "text/html",
"value": "https://www.example.com/terms-of-use"
}
]
}
],
"nameservers": [
{
"objectClassName": "nameserver",
"ldhName": "ns1.example.com"
},
{
"objectClassName": "nameserver",
"ldhName": "ns2.example.com"
}
],
"entities": [
{
"objectClassName": "entity",
"handle": "123",
"roles": [
"registrar"
],
"publicIds": [
{
"type": "IANA Registrar ID",
"identifier": "1"
}
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Example Registrar Inc."
],
[
"adr",
{},
"text",
[
"",
"Suite 100",
"123 Example Dr.",
"Dulles",
"VA",
"20166-6503",
"US"
]
],
[
"email",
{},
"text",
"contact@organization.example"
],
[
"tel",
{
"type": "voice"
},
"uri",
"tel:+1.7035555555"
],
[
"tel",
{
"type": "fax"
},
"uri",
"tel:+1.7035555556"
]
]
],
"entities": [
{
"objectClassName": "entity",
"roles": [
"abuse"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Abuse Contact"
],
[
"email",
{},
"text",
"abuse@organization.example"
],
[
"tel",
{
"type": "voice"
},
"uri",
"tel:+1.7035555555"
]
]
]
}
]
},
{
"objectClassName": "entity",
"handle": "XXXX",
"roles": [
"registrant"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"*REDACTED*"
],
[
"adr",
{},
"text",
[
"*REDACTED*",
"*REDACTED*",
"*REDACTED*",
"*REDACTED*",
"QC",
"*REDACTED*",
"Canada"
]
]
]
]
},
{
"objectClassName": "entity",
"handle": "YYYY",
"roles": [
"technical"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"*REDACTED*"
],
[
"org",
{},
"text",
"Example Inc."
],
[
"adr",
{},
"text",
[
"",
"Suite 1234",
"4321 Rue Somewhere",
"Quebec",
"QC",
"G1V 2M2",
"Canada"
]
]
]
]
}
],
"events": [
{
"eventAction": "registration",
"eventDate": "1997-06-03T00:00:00Z"
},
{
"eventAction": "last changed",
"eventDate": "2020-05-28T01:35:00Z"
},
{
"eventAction": "expiration",
"eventDate": "2021-06-03T04:00:00Z"
}
],
"status": [
"server delete prohibited",
"server update prohibited",
"server transfer prohibited",
"client transfer prohibited"
],
"redacted": [
{
"name": {
"description": "Registrant Name"
},
"postPath": "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='fn')][3]",
"pathLang": "jsonpath",
"method": "partialValue",
"reason": {
"description": "Server policy"
}
},
{
"name": {
"description": "Registrant Street"
},
"postPath": "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='adr')][3][:3]",
"pathLang": "jsonpath",
"method": "partialValue",
"reason": {
"description": "Server policy"
}
},
{
"name": {
"description": "Registrant City"
},
"postPath": "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='adr')][3][3]",
"pathLang": "jsonpath",
"method": "partialValue",
"reason": {
"description": "Server policy"
}
},
{
"name": {
"description": "Registrant Postal Code"
},
"postPath": "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='adr')][3][5]",
"pathLang": "jsonpath",
"method": "partialValue",
"reason": {
"description": "Server policy"
}
},
{
"name": {
"description": "Technical Name"
},
"postPath": "$.entities[?(@.roles[0]=='technical')].vcardArray[1][?(@[0]=='fn')][3]",
"method": "partialValue",
"reason": {
"description": "Server policy"
}
}
]
}

View file

@ -0,0 +1,283 @@
{
"rdapConformance": [
"rdap_level_0",
"redacted"
],
"objectClassName": "domain",
"ldhName": "example-3.net",
"secureDNS": { "delegationSigned": false },
"notices": [
{
"title": "Terms of Use",
"description": [ "Service subject to Terms of Use." ],
"links": [
{
"rel": "self",
"href": "https://www.example.com/terms-of-use",
"type": "text/html",
"value": "https://www.example.com/terms-of-use"
}
]
}
],
"nameservers": [
{
"objectClassName": "nameserver", "ldhName": "ns1.example.com" },
{
"objectClassName": "nameserver", "ldhName": "ns2.example.com" }
],
"entities": [
{
"objectClassName": "entity",
"handle": "123",
"roles": [ "registrar" ],
"publicIds": [
{ "type": "IANA Registrar ID", "identifier": "1" }
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Example Registrar Inc."
],
[
"adr",
{},
"text",
[
"",
"Suite 100",
"123 Example Dr.",
"Dulles",
"VA",
"20166-6503",
"US"
]
],
[
"email",
{},
"text",
"contact@organization.example"
],
[
"tel",
{
"type": "voice"
},
"uri",
"tel:+1.7035555555"
],
[
"tel",
{
"type": "fax"
},
"uri",
"tel:+1.7035555556"
]
]
],
"entities": [
{
"objectClassName": "entity",
"roles": [
"abuse"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Abuse Contact"
],
[
"email",
{},
"text",
"abuse@organization.example"
],
[
"tel",
{
"type": "voice"
},
"uri",
"tel:+1.7035555555"
]
]
]
}
]
},
{
"objectClassName": "entity",
"handle": "XXXX",
"roles": [
"registrant"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
""
],
[
"adr",
{},
"text",
[
"",
"",
"",
"",
"QC",
"",
"Canada"
]
]
]
]
},
{
"objectClassName": "entity",
"handle": "YYYY",
"roles": [
"technical"
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
""
],
[
"org",
{},
"text",
"Example Inc."
],
[
"adr",
{},
"text",
[
"",
"Suite 1234",
"4321 Rue Somewhere",
"Quebec",
"QC",
"G1V 2M2",
"Canada"
]
]
]
]
}
],
"events": [
{
"eventAction": "registration", "eventDate": "1997-06-03T00:00:00Z"
},
{
"eventAction": "last changed", "eventDate": "2020-05-28T01:35:00Z"
},
{
"eventAction": "expiration", "eventDate": "2021-06-03T04:00:00Z"
}
],
"status": [
"server delete prohibited", "server update prohibited", "server transfer prohibited", "client transfer prohibited"
],
"redacted": [
{
"name": {
"description": "Registrant Name"
},
"postPath": "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='fn')][3]",
"pathLang": "jsonpath",
"method": "partialValue",
"reason": {
"description": "Server policy"
}
},
{
"name": {
"description": "Registrant Street"
},
"postPath": "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='adr')][3][:3]",
"pathLang": "jsonpath",
"method": "partialValue",
"reason": {
"description": "Server policy"
}
},
{
"name": {
"description": "Registrant City"
},
"postPath": "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='adr')][3][3]",
"pathLang": "jsonpath",
"method": "partialValue",
"reason": {
"description": "Server policy"
}
},
{
"name": {
"description": "Registrant Postal Code"
},
"postPath": "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='adr')][3][5]",
"pathLang": "jsonpath",
"method": "partialValue",
"reason": {
"description": "Server policy"
}
},
{
"name": {
"description": "Technical Name"
},
"postPath": "$.entities[?(@.roles[0]=='technical')].vcardArray[1][?(@[0]=='fn')][3]",
"method": "partialValue",
"reason": {
"description": "Server policy"
}
}
]
}

View file

@ -0,0 +1,212 @@
{
"rdapConformance": [
"rdap_level_0",
"redacted"
],
"objectClassName": "domain",
"handle": "PPP",
"ldhName": "0.2.192.in-addr.arpa",
"nameservers": [
{
"objectClassName": "nameserver",
"ldhName": "ns1.rir.example"
},
{
"objectClassName": "nameserver",
"ldhName": "ns2.rir.example"
}
],
"secureDNS": {
"delegationSigned": true,
"dsData": [
{
"keyTag": 25345,
"algorithm": 8,
"digestType": 2,
"digest": "2788970E18EA14...C890C85B8205B94"
}
]
},
"remarks": [
{
"description": [
"She sells sea shells down by the sea shore.",
"Originally written by Terry Sullivan."
]
}
],
"links": [
{
"value": "https://example.net/domain/0.2.192.in-addr.arpa",
"rel": "self",
"href": "https://example.net/domain/0.2.192.in-addr.arpa",
"type": "application/rdap+json"
}
],
"events": [
{
"eventAction": "registration",
"eventDate": "1990-12-31T23:59:59Z"
},
{
"eventAction": "last changed",
"eventDate": "1991-12-31T23:59:59Z",
"eventActor": "joe@example.com"
}
],
"entities": [
{
"objectClassName": "entity",
"handle": "XXXX",
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Joe User"
],
[
"kind",
{},
"text",
"individual"
],
[
"lang",
{
"pref": "1"
},
"language-tag",
"fr"
],
[
"lang",
{
"pref": "2"
},
"language-tag",
"en"
],
[
"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"
]
]
],
"roles": [
"registrant"
],
"remarks": [
{
"description": [
"She sells sea shells down by the sea shore.",
"Originally written by Terry Sullivan."
]
}
],
"links": [
{
"value": "https://example.net/entity/XXXX",
"rel": "self",
"href": "https://example.net/entity/XXXX",
"type": "application/rdap+json"
}
],
"events": [
{
"eventAction": "registration",
"eventDate": "1990-12-31T23:59:59Z"
},
{
"eventAction": "last changed",
"eventDate": "1991-12-31T23:59:59Z",
"eventActor": "joe@example.com"
}
]
}
],
"network": {
"objectClassName": "ip network",
"handle": "XXXX-RIR",
"startAddress": "192.0.2.0",
"endAddress": "192.0.2.255",
"ipVersion": "v4",
"name": "NET-RTR-1",
"type": "DIRECT ALLOCATION",
"country": "AU",
"parentHandle": "YYYY-RIR",
"status": [
"active"
]
},
"redacted": [
{
"name": {
"description": "Registrant keyTag"
},
"postPath": "$['secureDNS']['dsData'][0]['keyTag']",
"pathLang": "jsonpath",
"method": "partialValue"
}
]
}

View file

@ -0,0 +1,24 @@
Domain Name: home.moscow
Registry Domain ID: 20211019192813345912_c936bef81d9614db04ffc278b29daf5a_domain-FIR
Creation Date: 2021-10-19T19:28:12Z
Updated Date: 2023-11-20T18:36:22Z
Registry Expiry Date: 2024-10-19T19:28:12Z
Domain Status: client transfer prohibited
Registrar Whois Server: whois.flexireg.net
Registrar: Limited Liability Company "Registrar of domain names REG.RU"
Registrar Street:
Registrar City:
Registrar State/Province:
Registrar Postal Code:
Registrar Country:
Registrar IANA ID: 1606
Registrar Abuse Contact Email: abuse@reg.ru
Registrar Abuse Contact Phone: tel:+7
Registrar Abuse Contact Phone: tel:495
Registrar Abuse Contact Phone: tel:5801111
Name Server: ns1.reg.ru
Name Server: ns2.reg.ru
URL of the ICANN Whois Inaccuracy Complaint Form: https://www.icann.org/wicf/
>>> Last update of RDAP database: 2024-06-18T22:01:39+03:00 <<<

View file

@ -0,0 +1,345 @@
{
"rdapConformance": [
"rdap_level_0",
"icann_rdap_response_profile_0",
"icann_rdap_technical_implementation_guide_0"
],
"notices": [
{
"title": "Terms of Use",
"description": [
"Terms of Use page."
],
"links": [
{
"value": "https://flexireg.net/ru/whois-terms_of_use.en.html",
"rel": "related",
"href": "https://flexireg.net/ru/whois-terms_of_use.en.html",
"type": "text/html"
}
]
},
{
"title": "Status Codes",
"description": [
"For more information on domain status codes, please visit https://icann.org/epp."
],
"links": [
{
"value": "https://icann.org/epp",
"rel": "related",
"href": "https://icann.org/epp",
"type": "text/html"
}
]
},
{
"title": "RDDS Inaccuracy Complaint Form",
"description": [
"URL of the ICANN RDDS Inaccuracy Complaint Form:https://icann.org/wicf"
],
"links": [
{
"value": "https://www.icann.org/wicf",
"rel": "related",
"href": "https://www.icann.org/wicf",
"type": "text/html"
}
]
}
],
"objectClassName": "domain",
"handle": "20211019192813345912_c936bef81d9614db04ffc278b29daf5a_domain-FIR",
"links": [
{
"value": "https://flexireg.net/moscow/rdap/domain/home.moscow",
"rel": "self",
"href": "https://flexireg.net/moscow/rdap/domain/home.moscow",
"type": "application/rdap+json"
}
],
"events": [
{
"eventAction": "registration",
"eventDate": "2021-10-19T19:28:12Z"
},
{
"eventAction": "last changed",
"eventDate": "2023-11-20T18:36:22Z"
},
{
"eventAction": "expiration",
"eventDate": "2024-10-19T19:28:12Z"
},
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T22:01:39+03:00"
}
],
"status": [
"client transfer prohibited"
],
"port43": "whois.flexireg.net",
"entities": [
{
"objectClassName": "entity",
"handle": "regru-msk-fir",
"links": [
{
"value": "https://flexireg.net/moscow/rdap/entity/regru-msk-fir",
"rel": "self",
"href": "https://flexireg.net/moscow/rdap/entity/regru-msk-fir",
"type": "application/rdap+json"
}
],
"entities": [
{
"objectClassName": "entity",
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Abuse contact"
],
[
"tel",
{
"pref": "1",
"type": [
"work",
"voice"
]
},
"uri",
"tel:+7"
],
[
"tel",
{
"pref": "1",
"type": [
"work",
"voice"
]
},
"uri",
"tel:495"
],
[
"tel",
{
"pref": "1",
"type": [
"work",
"voice"
]
},
"uri",
"tel:5801111"
],
[
"email",
{
"type": "work"
},
"text",
"abuse@reg.ru"
]
]
],
"roles": [
"abuse"
]
}
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Limited Liability Company \"Registrar of domain names REG.RU\""
],
[
"adr",
{
"type": "work"
},
"text",
[
"",
"",
"",
"",
"",
"",
""
]
],
[
"email",
{
"type": "work"
},
"text",
"info@reg.ru"
]
]
],
"roles": [
"registrar"
],
"publicIds": [
{
"type": "IANA Registrar ID",
"identifier": "1606"
}
]
},
{
"objectClassName": "entity",
"handle": "20231120183619454758_28c280fc7dcaa0dff7cd2b0e9b87e263_contact-FIR",
"remarks": [
{
"title": "REDACTED FOR PRIVACY",
"description": [
"Some of the data in this object has been removed"
]
},
{
"title": "EMAIL REDACTED FOR PRIVACY",
"description": [
"Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant of the queried domain name"
]
}
],
"links": [
{
"value": "https://flexireg.net/moscow/rdap/entity/20231120183619454758_28c280fc7dcaa0dff7cd2b0e9b87e263_contact-FIR",
"rel": "self",
"href": "https://flexireg.net/moscow/rdap/entity/20231120183619454758_28c280fc7dcaa0dff7cd2b0e9b87e263_contact-FIR",
"type": "application/rdap+json"
}
],
"roles": [
"registrant"
]
},
{
"objectClassName": "entity",
"handle": "20231120183619841977_163830e2aa5d03e5d5f7d2a1b936acec_contact-FIR",
"remarks": [
{
"title": "REDACTED FOR PRIVACY",
"description": [
"Some of the data in this object has been removed"
]
},
{
"title": "EMAIL REDACTED FOR PRIVACY",
"description": [
"Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant of the queried domain name"
]
}
],
"links": [
{
"value": "https://flexireg.net/moscow/rdap/entity/20231120183619841977_163830e2aa5d03e5d5f7d2a1b936acec_contact-FIR",
"rel": "self",
"href": "https://flexireg.net/moscow/rdap/entity/20231120183619841977_163830e2aa5d03e5d5f7d2a1b936acec_contact-FIR",
"type": "application/rdap+json"
}
],
"roles": [
"administrative"
]
},
{
"objectClassName": "entity",
"handle": "20231120183621248371_e50abb5e0ee9095975084ceb57105843_contact-FIR",
"remarks": [
{
"title": "REDACTED FOR PRIVACY",
"description": [
"Some of the data in this object has been removed"
]
},
{
"title": "EMAIL REDACTED FOR PRIVACY",
"description": [
"Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant of the queried domain name"
]
}
],
"links": [
{
"value": "https://flexireg.net/moscow/rdap/entity/20231120183621248371_e50abb5e0ee9095975084ceb57105843_contact-FIR",
"rel": "self",
"href": "https://flexireg.net/moscow/rdap/entity/20231120183621248371_e50abb5e0ee9095975084ceb57105843_contact-FIR",
"type": "application/rdap+json"
}
],
"roles": [
"technical"
]
}
],
"ldhName": "home.moscow",
"secureDNS": {
"delegationSigned": false
},
"nameservers": [
{
"objectClassName": "nameserver",
"links": [
{
"value": "https://flexireg.net/moscow/rdap/nameserver/ns1.reg.ru",
"rel": "self",
"href": "https://flexireg.net/moscow/rdap/nameserver/ns1.reg.ru",
"type": "application/rdap+json"
}
],
"events": [
{
"eventAction": "registration",
"eventDate": "2015-02-05T12:41:57Z"
}
],
"ldhName": "ns1.reg.ru"
},
{
"objectClassName": "nameserver",
"links": [
{
"value": "https://flexireg.net/moscow/rdap/nameserver/ns2.reg.ru",
"rel": "self",
"href": "https://flexireg.net/moscow/rdap/nameserver/ns2.reg.ru",
"type": "application/rdap+json"
}
],
"events": [
{
"eventAction": "registration",
"eventDate": "2015-02-05T12:41:57Z"
}
],
"ldhName": "ns2.reg.ru"
}
]
}

View file

@ -0,0 +1,52 @@
Domain Name: lemonde.fr
Registry Domain ID: DOM000000024309-FRNIC
Creation Date: 2005-08-02T14:16:36Z
Registry Expiry Date: 2025-06-09T22:08:09Z
Updated Date: 2024-06-12T22:09:39.881923Z
Domain Status: server update prohibited
Domain Status: server transfer prohibited
Domain Status: server delete prohibited
Domain Status: server recover prohibited
Registrar Whois Server: whois.nameshield.net
Registrar: NAMESHIELD
Registrar Street: 39 boulevard des Capucines
Registrar City: PARIS
Registrar State/Province:
Registrar Postal Code: 75002
Registrar Country: FR
Registrar IANA ID: 1251
Technical Name: NAMESHIELD
Technical Organization: TECHNICAL department
Technical Street: 79 rue Desjardins
Technical City: ANGERS
Technical State/Province:
Technical Postal Code: 49100
Technical Country: FR
Technical Email: technical@nameshield.net
Technical Phone: +33.241182828
Technical Fax: +33.241182829
Registrant Name: SOCIETE EDITRICE du monde
Registrant Organization: SOCIETE EDITRICE DU MONDE
Registrant Street: 67-69 avenue Pierre Mendes-France
Registrant City: PARIS
Registrant State/Province:
Registrant Postal Code: 75013
Registrant Country: FR
Registrant Email: domain_names@lemonde.fr
Registrant Phone: +33.157282224
Administrative Name: SOCIETE EDITRICE du monde
Administrative Organization: SOCIETE EDITRICE DU MONDE
Admin Street: 67-69 avenue Pierre Mendes-France
Admin City: PARIS
Admin State/Province:
Admin Postal Code: 75013
Admin Country: FR
Administrative Email: domain_names@lemonde.fr
Administrative Phone: +33.157282224
Name Server: ns-cloud-b4.googledomains.com
Name Server: ns-cloud-b2.googledomains.com
Name Server: ns-cloud-b3.googledomains.com
Name Server: ns-cloud-b1.googledomains.com
URL of the ICANN Whois Inaccuracy Complaint Form: https://www.icann.org/wicf/

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
Domain Name: microsoft.click
Registry Domain ID: DO_a7aec7e93f5797ee898b23cefe340fe3-UR
Creation Date: 2014-11-12T19:15:55.283Z
Registry Expiry Date: 2024-11-12T19:15:55.283Z
Updated Date: 2023-10-17T10:47:21.733Z
Domain Status: client update prohibited
Domain Status: client transfer prohibited
Domain Status: client delete prohibited
Registrar: MarkMonitor Inc.
Registrar Street: 3540 East Longwing Lane, Suite 300
Registrar City: Meridian
Registrar State/Province: ID
Registrar Postal Code: 83646
Registrar Country: US
Registrar Abuse Contact Email: abusecomplaints@markmonitor.com
Registrar Abuse Contact Phone: tel:+1.2083895740
Registrar Abuse Contact Phone: tel:+1.2083895771
Registrant Organization: Microsoft Corporation
Registrant Street:
Registrant City:
Registrant State/Province: WA
Registrant Postal Code:
Registrant Country: US
Admin Street:
Admin City:
Admin State/Province:
Admin Postal Code:
Admin Country:
Technical Street:
Technical City:
Technical State/Province:
Technical Postal Code:
Technical Country:
Name Server: ns4-08.azure-dns.info
Name Server: ns2-08.azure-dns.net
Name Server: ns3-08.azure-dns.org
Name Server: ns1-08.azure-dns.com
URL of the ICANN Whois Inaccuracy Complaint Form: https://www.icann.org/wicf/
>>> Last update of RDAP database: 2024-06-18T14:25:27.257Z <<<

View file

@ -0,0 +1,455 @@
{
"rdapConformance": [
"icann_rdap_technical_implementation_guide_0",
"ur_domain_check_0"
],
"notices": [
{
"title": "Status Codes",
"description": [
"For more information on domain status codes, please visit https://icann.org/epp"
],
"links": [
{
"href": "https://icann.org/epp"
}
]
},
{
"title": "RDDS Inaccuracy Complaint Form",
"description": [
"URL of the ICANN RDDS Inaccuracy Complaint Form: https://www.icann.org/wicf/"
],
"links": [
{
"href": "https://www.icann.org/wicf/"
}
]
},
{
"title": "Terms of service",
"description": [
"The WHOIS information provided in this page has been redacted",
"in compliance with ICANN's Temporary Specification for gTLD",
"Registration Data.",
"",
"The data in this record is provided by Uniregistry for informational",
"purposes only, and it does not guarantee its accuracy. Uniregistry is",
"authoritative for whois information in top-level domains it operates",
"under contract with the Internet Corporation for Assigned Names and",
"Numbers. Whois information from other top-level domains is provided by",
"a third-party under license to Uniregistry.",
"",
"This service is intended only for query-based access. By using this",
"service, you agree that you will use any data presented only for lawful",
"purposes and that, under no circumstances will you use (a) data",
"acquired for the purpose of allowing, enabling, or otherwise supporting",
"the transmission by e-mail, telephone, facsimile or other",
"communications mechanism of mass unsolicited, commercial advertising",
"or solicitations to entities other than your existing customers; or",
"(b) this service to enable high volume, automated, electronic processes",
"that send queries or data to the systems of any Registrar or any",
"Registry except as reasonably necessary to register domain names or",
"modify existing domain name registrations.",
"",
"Uniregistry reserves the right to modify these terms at any time. By",
"submitting this query, you agree to abide by this policy. All rights",
"reserved.",
""
],
"links": [
{
"href": "https://whois.uniregistry.net/"
}
]
}
],
"objectClassName": "domain",
"handle": "DO_a7aec7e93f5797ee898b23cefe340fe3-UR",
"events": [
{
"eventAction": "registration",
"eventActor": "markmonitor",
"eventDate": "2014-11-12T19:15:55.283Z"
},
{
"eventAction": "expiration",
"eventDate": "2024-11-12T19:15:55.283Z"
},
{
"eventAction": "last changed",
"eventDate": "2023-10-17T10:47:21.733Z"
},
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T14:25:27.257Z"
}
],
"status": [
"client update prohibited",
"client transfer prohibited",
"client delete prohibited"
],
"entities": [
{
"objectClassName": "entity",
"remarks": [
{
"title": "REDACTED FOR PRIVACY",
"description": [
"Some of the data in this object has been removed"
]
},
{
"title": "EMAIL REDACTED FOR PRIVACY",
"description": [
"Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant, Admin, or Tech contact of the queried domain name."
]
}
],
"events": [
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T14:30:59.585Z"
}
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"org",
{},
"text",
"Microsoft Corporation"
],
[
"adr",
{},
"text",
[
"",
"",
"",
"",
"WA",
"",
"US"
]
]
]
],
"roles": [
"registrant"
]
},
{
"objectClassName": "entity",
"remarks": [
{
"title": "REDACTED FOR PRIVACY",
"description": [
"Some of the data in this object has been removed"
]
},
{
"title": "EMAIL REDACTED FOR PRIVACY",
"description": [
"Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant, Admin, or Tech contact of the queried domain name."
]
}
],
"events": [
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T14:30:59.585Z"
}
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"adr",
{},
"text",
[
"",
"",
"",
"",
""
]
]
]
],
"roles": [
"administrative"
]
},
{
"objectClassName": "entity",
"remarks": [
{
"title": "REDACTED FOR PRIVACY",
"description": [
"Some of the data in this object has been removed"
]
},
{
"title": "EMAIL REDACTED FOR PRIVACY",
"description": [
"Please query the RDDS service of the Registrar of Record identified in this output for information on how to contact the Registrant, Admin, or Tech contact of the queried domain name."
]
}
],
"events": [
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T14:30:59.585Z"
}
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"adr",
{},
"text",
[
"",
"",
"",
"",
""
]
]
]
],
"roles": [
"technical"
]
},
{
"objectClassName": "entity",
"handle": "292",
"events": [
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T14:30:59.585Z"
}
],
"entities": [
{
"objectClassName": "entity",
"handle": "CO_03a1e5a41de9801039c48cce9ea7414f-UR",
"events": [
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T14:30:59.585Z"
}
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"Markmonitor"
],
[
"adr",
{},
"text",
[
"",
"",
[
"2150 S Bonito Way, Suite 150",
"",
""
],
"Meridian",
"ID",
"83642",
"US"
]
],
[
"tel",
{
"type": "voice"
},
"uri",
"tel:+1.2083895740"
],
[
"tel",
{
"type": "fax"
},
"uri",
"tel:+1.2083895771"
],
[
"email",
{},
"text",
"abusecomplaints@markmonitor.com"
]
]
],
"roles": [
"abuse"
]
}
],
"vcardArray": [
"vcard",
[
[
"version",
{},
"text",
"4.0"
],
[
"fn",
{},
"text",
"MarkMonitor Inc."
],
[
"adr",
{},
"text",
[
"",
"",
"3540 East Longwing Lane, Suite 300",
"Meridian",
"ID",
"83646",
"US"
]
],
[
"tel",
{
"type": "voice"
},
"uri",
"tel:+1.208389574"
],
[
"tel",
{
"type": "fax"
},
"uri",
"tel:+1.2083895771"
],
[
"email",
{},
"text",
"ccops@markmonitor.com"
]
]
],
"roles": [
"registrar"
]
}
],
"ldhName": "microsoft.click",
"unicodeName": "microsoft.click",
"secureDNS": {
"delegationSigned": false
},
"nameservers": [
{
"objectClassName": "nameserver",
"handle": "HO_6d2f0b70100a174318954d7e2af08b36-UR",
"events": [
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T14:30:59.585Z"
}
],
"status": [
"associated"
],
"ldhName": "ns4-08.azure-dns.info",
"unicodeName": "ns4-08.azure-dns.info"
},
{
"objectClassName": "nameserver",
"handle": "HO_a639e2ec1bd022f8dcf45d00dfc1cf7d-UR",
"events": [
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T14:30:59.585Z"
}
],
"status": [
"associated"
],
"ldhName": "ns2-08.azure-dns.net",
"unicodeName": "ns2-08.azure-dns.net"
},
{
"objectClassName": "nameserver",
"handle": "HO_bcdf18efa72577e5ac61514bce694770-UR",
"events": [
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T14:30:59.585Z"
}
],
"status": [
"associated"
],
"ldhName": "ns3-08.azure-dns.org",
"unicodeName": "ns3-08.azure-dns.org"
},
{
"objectClassName": "nameserver",
"handle": "HO_8fa40a46321cfbe1b88b1590e5bd9cea-UR",
"events": [
{
"eventAction": "last update of RDAP database",
"eventDate": "2024-06-18T14:30:59.585Z"
}
],
"status": [
"associated"
],
"ldhName": "ns1-08.azure-dns.com",
"unicodeName": "ns1-08.azure-dns.com"
}
]
}

View file

@ -0,0 +1,28 @@
[package]
name = "icann-rdap-common"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = """
Common RDAP data structures.
"""
[dependencies]
chrono.workspace = true
cidr.workspace = true
const_format.workspace = true
buildstructor.workspace = true
idna.workspace = true
ipnet.workspace = true
prefix-trie.workspace = true
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
strum_macros.workspace = true
thiserror.workspace = true
[dev-dependencies]
# fixture testings
rstest = "0.17.0"

177
icann-rdap-common/README.md Normal file
View file

@ -0,0 +1,177 @@
ICANN RDAP Common
=================
This is a common component library for the Registration Data Access Protocol (RDAP) written and sponsored
by the Internet Corporation for Assigned Names and Numbers [(ICANN)](https://www.icann.org).
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
------------
Add the library to your Cargo.toml: `cargo add icann-rdap-common`.
This library can be compiled for WASM targets.
Usage
-----
Create some RDAP objects:
```rust
// create an entity
use icann_rdap_common::response::Entity;
let holder = Entity::builder().handle("foo-BAR").build();
// create an RDAP domain
use icann_rdap_common::response::Domain;
let domain = Domain::builder().ldh_name("example.com").entity(holder.clone()).build();
// create an IP network
use icann_rdap_common::response::Network;
let net = Network::builder().cidr("10.0.0.0/16").entity(holder.clone()).build().unwrap();
// create a nameserver
use icann_rdap_common::response::Nameserver;
let ns = Nameserver::builder().ldh_name("ns1.example.com").entity(holder.clone()).build().unwrap();
// create an autnum
use icann_rdap_common::response::Autnum;
let autnum = Autnum::builder().autnum_range(700..700).entity(holder).build();
```
Parse RDAP JSON:
```rust
use icann_rdap_common::prelude::*;
let json = r#"
{
"objectClassName": "ip network",
"links": [
{
"value": "http://localhost:3000/rdap/ip/10.0.0.0/16",
"rel": "self",
"href": "http://localhost:3000/rdap/ip/10.0.0.0/16",
"type": "application/rdap+json"
}
],
"events": [
{
"eventAction": "registration",
"eventDate": "2023-06-16T22:56:49.594173356+00:00"
},
{
"eventAction": "last changed",
"eventDate": "2023-06-16T22:56:49.594189140+00:00"
}
],
"startAddress": "10.0.0.0",
"endAddress": "10.0.255.255",
"ipVersion": "v4"
}
"#;
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<Value> = serde_json::from_str(json).unwrap();
let contact = Contact::from_vcard(&data);
```
License
-------
Licensed under either of
* Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
* MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT) at your option.
Contribution
------------
Unless you explicitly state otherwise, any contribution, as defined in the Apache-2.0 license,
intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license,
shall be dual licensed pursuant to the Apache License, Version 2.0 or the MIT License referenced
as above, at ICANNs option, without any additional terms or conditions.

View file

@ -0,0 +1,192 @@
use std::any::TypeId;
use crate::response::autnum::Autnum;
use super::{
string::StringCheck, Check, CheckParams, Checks, GetChecks, GetSubChecks, RdapStructure,
};
impl GetChecks for Autnum {
fn get_checks(&self, params: CheckParams) -> super::Checks {
let sub_checks = if params.do_subchecks {
let mut sub_checks: Vec<Checks> = self
.common
.get_sub_checks(params.from_parent(TypeId::of::<Self>()));
sub_checks.append(
&mut self
.object_common
.get_sub_checks(params.from_parent(TypeId::of::<Self>())),
);
sub_checks
} else {
vec![]
};
let mut items = vec![];
if self.start_autnum.is_none() || self.end_autnum.is_none() {
items.push(Check::AutnumMissing.check_item())
}
if let Some(start_num) = &self.start_autnum.as_ref().and_then(|n| n.as_u32()) {
if let Some(end_num) = &self.end_autnum.as_ref().and_then(|n| n.as_u32()) {
if start_num > end_num {
items.push(Check::AutnumEndBeforeStart.check_item())
}
if is_autnum_reserved(*start_num) || is_autnum_reserved(*end_num) {
items.push(Check::AutnumReserved.check_item())
}
if is_autnum_documentation(*start_num) || is_autnum_documentation(*end_num) {
items.push(Check::AutnumDocumentation.check_item())
}
if is_autnum_private_use(*start_num) || is_autnum_private_use(*end_num) {
items.push(Check::AutnumPrivateUse.check_item())
}
}
}
if let Some(name) = &self.name {
if name.is_whitespace_or_empty() {
items.push(Check::NetworkOrAutnumNameIsEmpty.check_item())
}
}
if let Some(autnum_type) = &self.autnum_type {
if autnum_type.is_whitespace_or_empty() {
items.push(Check::NetworkOrAutnumTypeIsEmpty.check_item())
}
}
Checks {
rdap_struct: RdapStructure::Autnum,
items,
sub_checks,
}
}
}
/// Returns true if the autnum is reserved.
pub fn is_autnum_reserved(autnum: u32) -> bool {
autnum == 0 || autnum == 65535 || autnum == 4294967295 || (65552..=131071).contains(&autnum)
}
/// Returns true if the autnum is for documentation.
pub fn is_autnum_documentation(autnum: u32) -> bool {
(64496..=64511).contains(&autnum) || (65536..=65551).contains(&autnum)
}
/// Returns true if the autnum is private use.
pub fn is_autnum_private_use(autnum: u32) -> bool {
(64512..=65534).contains(&autnum) || (4200000000..=4294967294).contains(&autnum)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use crate::prelude::ToResponse;
use crate::{
check::{Check, CheckParams, GetChecks},
response::autnum::Autnum,
};
use super::*;
#[test]
fn check_autnum_with_empty_name() {
// GIVEN
let mut autnum = Autnum::builder().autnum_range(700..700).build();
autnum.name = Some("".to_string());
let rdap = autnum.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::NetworkOrAutnumNameIsEmpty));
}
#[test]
fn check_autnum_with_empty_type() {
// GIVEN
let mut autnum = Autnum::builder().autnum_range(700..700).build();
autnum.autnum_type = Some("".to_string());
let rdap = autnum.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::NetworkOrAutnumTypeIsEmpty));
}
#[rstest]
#[case(0, true)]
#[case(65535, true)]
#[case(65552, true)]
#[case(131071, true)]
#[case(4294967295, true)]
#[case(1, false)]
#[case(65534, false)]
#[case(65551, false)]
#[case(131072, false)]
#[case(4294967294, false)]
fn check_autnum_is_reserved(#[case] autnum: u32, #[case] expected: bool) {
// GIVEN in parameters
// WHEN
let actual = is_autnum_reserved(autnum);
// THEN
assert_eq!(actual, expected);
}
#[rstest]
#[case(64496, true)]
#[case(64511, true)]
#[case(65536, true)]
#[case(65551, true)]
#[case(64495, false)]
#[case(64512, false)]
#[case(65535, false)]
#[case(65552, false)]
fn check_autnum_is_documentation(#[case] autnum: u32, #[case] expected: bool) {
// GIVEN in parameters
// WHEN
let actual = is_autnum_documentation(autnum);
// THEN
assert_eq!(actual, expected);
}
#[rstest]
#[case(64512, true)]
#[case(65534, true)]
#[case(4200000000, true)]
#[case(4294967294, true)]
#[case(65534, true)]
#[case(64511, false)]
#[case(65535, false)]
#[case(4199999999, false)]
#[case(4294967295, false)]
fn check_autnum_is_private_use(#[case] autnum: u32, #[case] expected: bool) {
// GIVEN in parameters
// WHEN
let actual = is_autnum_private_use(autnum);
// THEN
assert_eq!(actual, expected);
}
}

View file

@ -0,0 +1,648 @@
use std::any::TypeId;
use crate::response::domain::{Domain, SecureDns};
use super::{string::StringCheck, Check, CheckParams, Checks, GetChecks, GetSubChecks};
impl GetChecks for Domain {
fn get_checks(&self, params: CheckParams) -> super::Checks {
let sub_checks = if params.do_subchecks {
let mut sub_checks: Vec<Checks> = self
.common
.get_sub_checks(params.from_parent(TypeId::of::<Self>()));
sub_checks.append(
&mut self
.object_common
.get_sub_checks(params.from_parent(TypeId::of::<Self>())),
);
if let Some(public_ids) = &self.public_ids {
sub_checks.append(&mut public_ids.get_sub_checks(params));
}
if let Some(secure_dns) = &self.secure_dns {
sub_checks.append(&mut secure_dns.get_sub_checks(params));
}
sub_checks
} else {
vec![]
};
let mut items = vec![];
// check variants
if let Some(variants) = &self.variants {
let empty_count = variants
.iter()
.filter(|v| {
v.relations.is_none() && v.idn_table.is_none() && v.variant_names.is_none()
})
.count();
if empty_count != 0 {
items.push(Check::VariantEmptyDomain.check_item());
};
};
// check ldh
if let Some(ldh) = &self.ldh_name {
if !ldh.is_ldh_domain_name() {
items.push(Check::LdhNameInvalid.check_item());
}
let name = ldh.trim_end_matches('.');
if name.eq("example")
|| name.ends_with(".example")
|| name.eq("example.com")
|| name.ends_with(".example.com")
|| name.eq("example.net")
|| name.ends_with(".example.net")
|| name.eq("example.org")
|| name.ends_with(".example.org")
{
items.push(Check::LdhNameDocumentation.check_item())
}
// if there is also a unicodeName
if let Some(unicode_name) = &self.unicode_name {
let expected = idna::domain_to_ascii(unicode_name);
if let Ok(expected) = expected {
if !expected.eq_ignore_ascii_case(ldh) {
items.push(Check::LdhNameDoesNotMatchUnicode.check_item())
}
}
}
}
// check unicode_name
if let Some(unicode_name) = &self.unicode_name {
if !unicode_name.is_unicode_domain_name() {
items.push(Check::UnicodeNameInvalidDomain.check_item());
}
let expected = idna::domain_to_ascii(unicode_name);
if expected.is_err() {
items.push(Check::UnicodeNameInvalidUnicode.check_item());
}
}
Checks {
rdap_struct: super::RdapStructure::Domain,
items,
sub_checks,
}
}
}
impl GetSubChecks for SecureDns {
fn get_sub_checks(&self, _params: CheckParams) -> Vec<Checks> {
let mut sub_checks = Vec::new();
if let Some(delegation_signed) = &self.delegation_signed {
if delegation_signed.is_string() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::DelegationSignedIsString.check_item()],
sub_checks: vec![],
});
}
}
if let Some(zone_signed) = &self.zone_signed {
if zone_signed.is_string() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::ZoneSignedIsString.check_item()],
sub_checks: vec![],
});
}
}
if let Some(max_sig_life) = &self.max_sig_life {
if max_sig_life.is_string() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::MaxSigLifeIsString.check_item()],
sub_checks: vec![],
});
}
}
if let Some(key_data) = &self.key_data {
for key_datum in key_data {
if let Some(alg) = &key_datum.algorithm {
if alg.is_string() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::KeyDatumAlgorithmIsString.check_item()],
sub_checks: vec![],
});
}
if alg.as_u8().is_none() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::KeyDatumAlgorithmIsOutOfRange.check_item()],
sub_checks: vec![],
});
}
}
if let Some(flags) = &key_datum.flags {
if flags.is_string() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::KeyDatumFlagsIsString.check_item()],
sub_checks: vec![],
});
}
if flags.as_u16().is_none() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::KeyDatumFlagsIsOutOfRange.check_item()],
sub_checks: vec![],
});
}
}
if let Some(protocol) = &key_datum.protocol {
if protocol.is_string() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::KeyDatumProtocolIsString.check_item()],
sub_checks: vec![],
});
}
if protocol.as_u8().is_none() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::KeyDatumProtocolIsOutOfRange.check_item()],
sub_checks: vec![],
});
}
}
}
}
if let Some(ds_data) = &self.ds_data {
for ds_datum in ds_data {
if let Some(alg) = &ds_datum.algorithm {
if alg.is_string() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::DsDatumAlgorithmIsString.check_item()],
sub_checks: vec![],
});
}
if alg.as_u8().is_none() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::DsDatumAlgorithmIsOutOfRange.check_item()],
sub_checks: vec![],
});
}
}
if let Some(key_tag) = &ds_datum.key_tag {
if key_tag.is_string() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::DsDatumKeyTagIsString.check_item()],
sub_checks: vec![],
});
}
if key_tag.as_u32().is_none() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::DsDatumKeyTagIsOutOfRange.check_item()],
sub_checks: vec![],
});
}
}
if let Some(digest_type) = &ds_datum.digest_type {
if digest_type.is_string() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::DsDatumDigestTypeIsString.check_item()],
sub_checks: vec![],
});
}
if digest_type.as_u8().is_none() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::SecureDns,
items: vec![Check::DsDatumDigestTypeIsOutOfRange.check_item()],
sub_checks: vec![],
});
}
}
}
}
sub_checks
}
}
#[cfg(test)]
mod tests {
use std::any::TypeId;
use {
crate::{
check::{is_checked, is_checked_item, GetSubChecks},
prelude::ToResponse,
response::domain::{Domain, SecureDns},
},
rstest::rstest,
};
use crate::check::{Check, CheckParams, GetChecks};
#[rstest]
#[case("")]
#[case(" ")]
#[case("_.")]
fn test_check_for_bad_ldh(#[case] ldh: &str) {
// GIVEN
let domain = Domain::builder().ldh_name(ldh).build();
let rdap = domain.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(is_checked_item(Check::LdhNameInvalid, &checks));
}
#[rstest]
#[case("")]
#[case(" ")]
fn test_check_for_bad_unicode(#[case] unicode: &str) {
// GIVEN
let domain = Domain::idn().unicode_name(unicode).build();
let rdap = domain.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(is_checked_item(Check::UnicodeNameInvalidDomain, &checks));
}
#[test]
fn test_check_for_ldh_unicode_mismatch() {
// GIVEN
let domain = Domain::idn()
.unicode_name("foo.com")
.ldh_name("xn--foo.com")
.build();
let rdap = domain.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(is_checked_item(Check::LdhNameDoesNotMatchUnicode, &checks));
}
#[test]
fn test_delegation_signed_as_string() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"delegationSigned": "true"
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert_eq!(checks.len(), 1);
assert!(is_checked(Check::DelegationSignedIsString, &checks));
}
#[test]
fn test_delegation_signed_as_bool() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"delegationSigned": true
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert!(checks.is_empty());
}
#[test]
fn test_zone_signed_as_string() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"zoneSigned": "false"
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert_eq!(checks.len(), 1);
assert!(is_checked(Check::ZoneSignedIsString, &checks));
}
#[test]
fn test_zone_signed_as_bool() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"zoneSigned": true
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert!(checks.is_empty());
}
#[test]
fn test_max_sig_life_as_string() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"maxSigLife": "123"
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert_eq!(checks.len(), 1);
assert!(is_checked(Check::MaxSigLifeIsString, &checks));
}
#[test]
fn test_max_sig_life_as_number() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"maxSigLife": 123
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert!(checks.is_empty());
}
#[test]
fn test_key_data_attributes_as_string() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"keyData": [
{
"algorithm": "13",
"flags": "13",
"protocol": "13"
}
]
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert_eq!(checks.len(), 3);
assert!(is_checked(Check::KeyDatumAlgorithmIsString, &checks));
assert!(is_checked(Check::KeyDatumFlagsIsString, &checks));
assert!(is_checked(Check::KeyDatumProtocolIsString, &checks));
}
#[test]
fn test_key_data_attributes_as_number() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"keyData": [
{
"algorithm": 13,
"flags": 13,
"protocol": 13
}
]
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert!(checks.is_empty());
}
#[test]
fn test_key_data_attributes_out_of_range() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"keyData": [
{
"algorithm": 1300,
"flags": 130000,
"protocol": 1300
}
]
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert_eq!(checks.len(), 3);
assert!(is_checked(Check::KeyDatumAlgorithmIsOutOfRange, &checks));
assert!(is_checked(Check::KeyDatumFlagsIsOutOfRange, &checks));
assert!(is_checked(Check::KeyDatumProtocolIsOutOfRange, &checks));
}
#[test]
fn test_ds_data_attributes_as_string() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"dsData": [
{
"algorithm": "13",
"keyTag": "13",
"digestType": "13"
}
]
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert_eq!(checks.len(), 3);
assert!(is_checked(Check::DsDatumAlgorithmIsString, &checks));
assert!(is_checked(Check::DsDatumKeyTagIsString, &checks));
assert!(is_checked(Check::DsDatumDigestTypeIsString, &checks));
}
#[test]
fn test_ds_data_attributes_as_number() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"dsData": [
{
"algorithm": 13,
"keyTag": 13,
"digestType": 13
}
]
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert!(checks.is_empty());
}
#[test]
fn test_ds_data_attributes_out_of_range() {
// GIVEN
let secure_dns = serde_json::from_str::<SecureDns>(
r#"{
"dsData": [
{
"algorithm": 1300,
"keyTag": 13000000000,
"digestType": 1300
}
]
}"#,
)
.unwrap();
// WHEN
let checks = secure_dns.get_sub_checks(CheckParams {
do_subchecks: false,
root: &Domain::builder()
.ldh_name("example.com")
.build()
.to_response(),
parent_type: TypeId::of::<SecureDns>(),
allow_unreg_ext: false,
});
// THEN
assert_eq!(checks.len(), 3);
assert!(is_checked(Check::DsDatumAlgorithmIsOutOfRange, &checks));
assert!(is_checked(Check::DsDatumKeyTagIsOutOfRange, &checks));
assert!(is_checked(Check::DsDatumDigestTypeIsOutOfRange, &checks));
}
}

View file

@ -0,0 +1,71 @@
use std::{any::TypeId, str::FromStr};
use crate::{
contact::Contact,
response::entity::{Entity, EntityRole},
};
use super::{
string::{StringCheck, StringListCheck},
Check, CheckParams, Checks, GetChecks, GetSubChecks, RdapStructure,
};
impl GetChecks for Entity {
fn get_checks(&self, params: CheckParams) -> super::Checks {
let sub_checks = if params.do_subchecks {
let mut sub_checks: Vec<Checks> = self
.common
.get_sub_checks(params.from_parent(TypeId::of::<Self>()));
sub_checks.append(
&mut self
.object_common
.get_sub_checks(params.from_parent(TypeId::of::<Self>())),
);
if let Some(public_ids) = &self.public_ids {
sub_checks.append(&mut public_ids.get_sub_checks(params));
}
sub_checks
} else {
vec![]
};
let mut items = vec![];
if let Some(roles) = &self.roles {
if roles.is_string() {
items.push(Check::RoleIsString.check_item());
}
let roles = roles.vec();
if roles.is_empty_or_any_empty_or_whitespace() {
items.push(Check::RoleIsEmpty.check_item());
} else {
for role in roles {
let r = EntityRole::from_str(role);
if r.is_err() {
items.push(Check::UnknownRole.check_item());
}
}
}
}
if let Some(vcard) = &self.vcard_array {
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(Check::VcardFnIsEmpty.check_item())
}
} else {
items.push(Check::VcardHasNoFn.check_item())
}
} else {
items.push(Check::VcardArrayIsEmpty.check_item())
}
}
Checks {
rdap_struct: RdapStructure::Entity,
items,
sub_checks,
}
}
}

View file

@ -0,0 +1,23 @@
use std::any::TypeId;
use crate::response::error::Rfc9083Error;
use super::{CheckParams, Checks, GetChecks, GetSubChecks};
impl GetChecks for Rfc9083Error {
fn get_checks(&self, params: CheckParams) -> super::Checks {
let sub_checks = if params.do_subchecks {
let sub_checks: Vec<Checks> = self
.common
.get_sub_checks(params.from_parent(TypeId::of::<Self>()));
sub_checks
} else {
vec![]
};
Checks {
rdap_struct: super::RdapStructure::Error,
items: vec![],
sub_checks,
}
}
}

View file

@ -0,0 +1,23 @@
use std::any::TypeId;
use crate::response::help::Help;
use super::{CheckParams, Checks, GetChecks, GetSubChecks};
impl GetChecks for Help {
fn get_checks(&self, params: CheckParams) -> super::Checks {
let sub_checks = if params.do_subchecks {
let sub_checks: Vec<Checks> = self
.common
.get_sub_checks(params.from_parent(TypeId::of::<Self>()));
sub_checks
} else {
vec![]
};
Checks {
rdap_struct: super::RdapStructure::Help,
items: vec![],
sub_checks,
}
}
}

View file

@ -0,0 +1,346 @@
use crate::{httpdata::HttpData, media_types::RDAP_MEDIA_TYPE, response::types::ExtensionId};
use super::{Check, Checks, GetChecks};
impl GetChecks for HttpData {
fn get_checks(&self, params: crate::check::CheckParams) -> crate::check::Checks {
let mut items = vec![];
// RFC checks
if let Some(allow_origin) = &self.access_control_allow_origin {
if !allow_origin.eq("*") {
items.push(Check::CorsAllowOriginStarRecommended.check_item())
}
} else {
items.push(Check::CorsAllowOriginRecommended.check_item())
}
if self.access_control_allow_credentials.is_some() {
items.push(Check::CorsAllowCredentialsNotRecommended.check_item())
}
if let Some(content_type) = &self.content_type {
if !content_type.starts_with(RDAP_MEDIA_TYPE) {
items.push(Check::ContentTypeIsNotRdap.check_item());
}
} else {
items.push(Check::ContentTypeIsAbsent.check_item());
}
// checks for ICANN profile
if params
.root
.has_extension_id(ExtensionId::IcannRdapTechnicalImplementationGuide0)
|| params
.root
.has_extension_id(ExtensionId::IcannRdapTechnicalImplementationGuide1)
{
if let Some(scheme) = &self.scheme {
if !scheme.eq_ignore_ascii_case("HTTPS") {
items.push(Check::MustUseHttps.check_item());
}
} else {
items.push(Check::MustUseHttps.check_item());
}
if let Some(allow_origin) = &self.access_control_allow_origin {
if !allow_origin.eq("*") {
items.push(Check::AllowOriginNotStar.check_item())
}
} else {
items.push(Check::AllowOriginNotStar.check_item())
}
}
Checks {
rdap_struct: super::RdapStructure::HttpData,
items,
sub_checks: vec![],
}
}
}
#[cfg(test)]
mod tests {
use crate::{
check::{Check, CheckParams, GetChecks},
httpdata::HttpData,
media_types::{JSON_MEDIA_TYPE, RDAP_MEDIA_TYPE},
prelude::{Common, ObjectCommon, ToResponse},
response::{domain::Domain, types::ExtensionId},
};
#[test]
fn check_not_rdap_media() {
// GIVEN an rdap response
let domain = Domain {
common: Common::level0()
.extension(ExtensionId::IcannRdapTechnicalImplementationGuide0.to_extension())
.build(),
object_common: ObjectCommon::domain().build(),
ldh_name: Some("foo.example".to_string()),
unicode_name: None,
variants: None,
secure_dns: None,
nameservers: None,
public_ids: None,
network: None,
};
let rdap = domain.to_response();
// and GIVEN httpdata with content type that is not RDAP media type
let http_data = HttpData::example().content_type(JSON_MEDIA_TYPE).build();
// WHEN checks are run
let checks = http_data.get_checks(CheckParams::for_rdap(&rdap));
// THEN incorrect media type check is found
assert!(checks
.items
.iter()
.any(|c| c.check == Check::ContentTypeIsNotRdap));
}
#[test]
fn check_exactly_rdap_media() {
// GIVEN an rdap response
let domain = Domain {
common: Common::level0()
.extension(ExtensionId::IcannRdapTechnicalImplementationGuide0.to_extension())
.build(),
object_common: ObjectCommon::domain().build(),
ldh_name: Some("foo.example".to_string()),
unicode_name: None,
variants: None,
secure_dns: None,
nameservers: None,
public_ids: None,
network: None,
};
let rdap = domain.to_response();
// and GIVEN httpdata with content type that is not RDAP media type
let http_data = HttpData::example().content_type(RDAP_MEDIA_TYPE).build();
// WHEN checks are run
let checks = http_data.get_checks(CheckParams::for_rdap(&rdap));
// THEN incorrect media type check is not found
assert!(!checks
.items
.iter()
.any(|c| c.check == Check::ContentTypeIsNotRdap));
}
#[test]
fn check_rdap_media_with_charset_parameter() {
// GIVEN an rdap response
let domain = Domain {
common: Common::level0()
.extension(ExtensionId::IcannRdapTechnicalImplementationGuide0.to_extension())
.build(),
object_common: ObjectCommon::domain().build(),
ldh_name: Some("foo.example".to_string()),
unicode_name: None,
variants: None,
secure_dns: None,
nameservers: None,
public_ids: None,
network: None,
};
let rdap = domain.to_response();
// and GIVEN httpdata with content type that is not RDAP media type with charset parameter
let mt = format!("{RDAP_MEDIA_TYPE};charset=UTF-8");
let http_data = HttpData::example().content_type(mt).build();
// WHEN checks are run
let checks = http_data.get_checks(CheckParams::for_rdap(&rdap));
// THEN incorrect media type check is not found
assert!(!checks
.items
.iter()
.any(|c| c.check == Check::ContentTypeIsNotRdap));
}
#[test]
fn check_media_type_absent() {
// GIVEN an rdap response
let domain = Domain {
common: Common::level0()
.extension(ExtensionId::IcannRdapTechnicalImplementationGuide0.to_extension())
.build(),
object_common: ObjectCommon::domain().build(),
ldh_name: Some("foo.example".to_string()),
unicode_name: None,
variants: None,
secure_dns: None,
nameservers: None,
public_ids: None,
network: None,
};
let rdap = domain.to_response();
// and GIVEN httpdata no content type
let http_data = HttpData::example().build();
// WHEN checks are run
let checks = http_data.get_checks(CheckParams::for_rdap(&rdap));
// THEN no media type check is found
assert!(checks
.items
.iter()
.any(|c| c.check == Check::ContentTypeIsAbsent));
}
#[test]
fn check_cors_header_with_tig() {
// GIVEN a response with gtld tig
let domain = Domain {
common: Common::level0()
.extension(ExtensionId::IcannRdapTechnicalImplementationGuide0.to_extension())
.build(),
object_common: ObjectCommon::domain().build(),
ldh_name: Some("foo.example".to_string()),
unicode_name: None,
variants: None,
secure_dns: None,
nameservers: None,
public_ids: None,
network: None,
};
let rdap = domain.to_response();
// and GIVEN a cors header with *
let http_data = HttpData::example().access_control_allow_origin("*").build();
// WHEN running checks
let checks = http_data.get_checks(CheckParams::for_rdap(&rdap));
// THEN no check is given
assert!(!checks
.items
.iter()
.any(|c| c.check == Check::AllowOriginNotStar));
}
#[test]
fn check_cors_header_with_foo_and_tig() {
// GIVEN a response with gtld tig extension
let domain = Domain {
common: Common::level0()
.extension(ExtensionId::IcannRdapTechnicalImplementationGuide0.to_extension())
.build(),
object_common: ObjectCommon::domain().build(),
ldh_name: Some("foo.example".to_string()),
unicode_name: None,
variants: None,
secure_dns: None,
nameservers: None,
public_ids: None,
network: None,
};
let rdap = domain.to_response();
// and GIVEN response with cors header of "foo" (not "*")
let http_data = HttpData::example()
.access_control_allow_origin("foo")
.build();
// WHEN running checks
let checks = http_data.get_checks(CheckParams::for_rdap(&rdap));
// THEN the check is found
assert!(checks
.items
.iter()
.any(|c| c.check == Check::AllowOriginNotStar));
}
#[test]
fn check_no_cors_header_and_tig() {
// GIVEN domain response with gtld tig extension id
let domain = Domain {
common: Common::level0()
.extension(ExtensionId::IcannRdapTechnicalImplementationGuide0.to_extension())
.build(),
object_common: ObjectCommon::domain().build(),
ldh_name: Some("foo.example".to_string()),
unicode_name: None,
variants: None,
secure_dns: None,
nameservers: None,
public_ids: None,
network: None,
};
let rdap = domain.to_response();
// and GIVEN a response with no cors header
let http_data = HttpData::example().build();
// WHEN running checks
let checks = http_data.get_checks(CheckParams::for_rdap(&rdap));
// THEN check for missing cors is found
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::AllowOriginNotStar));
}
#[test]
fn given_response_is_over_https_and_tig() {
// GIVEN response with gtld tig extension
let domain = Domain {
common: Common::level0()
.extension(ExtensionId::IcannRdapTechnicalImplementationGuide0.to_extension())
.build(),
object_common: ObjectCommon::domain().build(),
ldh_name: Some("foo.example".to_string()),
unicode_name: None,
variants: None,
secure_dns: None,
nameservers: None,
public_ids: None,
network: None,
};
let rdap = domain.to_response();
// and GIVEN response is over https
let http_data = HttpData::now().scheme("https").host("example.com").build();
// WHEN running checks
let checks = http_data.get_checks(CheckParams::for_rdap(&rdap));
// THEN then check for must use https is not present
assert!(!checks.items.iter().any(|c| c.check == Check::MustUseHttps));
}
#[test]
fn response_over_htttp_and_tig() {
// GIVEN domain response with gtld tig extension
let domain = Domain {
common: Common::level0()
.extension(ExtensionId::IcannRdapTechnicalImplementationGuide0.to_extension())
.build(),
object_common: ObjectCommon::domain().build(),
ldh_name: Some("foo.example".to_string()),
unicode_name: None,
variants: None,
secure_dns: None,
nameservers: None,
public_ids: None,
network: None,
};
let rdap = domain.to_response();
// and GIVEN response is with http (not https)
let http_data = HttpData::now().scheme("http").host("example.com").build();
// WHEN running checks
let checks = http_data.get_checks(CheckParams::for_rdap(&rdap));
// THEN check for must use https is found
assert!(checks.items.iter().any(|c| c.check == Check::MustUseHttps));
}
}

View file

@ -0,0 +1,754 @@
//! Conformance checks of RDAP structures.
use std::{any::TypeId, sync::LazyLock};
use {
crate::response::RdapResponse,
serde::{Deserialize, Serialize},
strum::{EnumMessage, IntoEnumIterator},
strum_macros::{Display, EnumIter, EnumMessage, EnumString, FromRepr},
};
#[doc(inline)]
pub use string::*;
mod autnum;
mod domain;
mod entity;
mod error;
mod help;
mod httpdata;
mod nameserver;
mod network;
mod search;
mod string;
mod types;
/// The max length of the check class string representations.
pub static CHECK_CLASS_LEN: LazyLock<usize> = LazyLock::new(|| {
CheckClass::iter()
.max_by_key(|x| x.to_string().len())
.map_or(8, |x| x.to_string().len())
});
/// Describes the classes of checks.
#[derive(
EnumIter,
EnumString,
Debug,
Display,
PartialEq,
Eq,
PartialOrd,
Ord,
Serialize,
Deserialize,
Clone,
Copy,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum CheckClass {
/// Informational
///
/// This class represents informational items.
#[strum(serialize = "Info")]
Informational,
/// Specification Note
///
/// This class represents notes about the RDAP response with respect to
/// the various RDAP and RDAP related specifications.
#[strum(serialize = "SpecNote")]
SpecificationNote,
/// STD 95 Warnings
///
/// This class represents warnings that may cause some clients to be unable
/// to conduct some operations.
#[strum(serialize = "StdWarn")]
StdWarning,
/// STD 95 Errors
///
/// This class represetns errors in the RDAP with respect to STD 95.
#[strum(serialize = "StdErr")]
StdError,
/// Cidr0 Errors
///
/// This class represents errors with respect to CIDR0.
#[strum(serialize = "Cidr0Err")]
Cidr0Error,
/// ICANN Profile Errors
///
/// This class represents errors with respect to the gTLD RDAP profile.
#[strum(serialize = "IcannErr")]
IcannError,
}
/// Represents the name of an RDAP structure for which a check appears.
///
/// An RDAP data structure is not the same as a Rust struct in that RDAP
/// data structures may consist of arrays and sometimes structured data
/// within a string.
#[derive(
Debug, Serialize, Deserialize, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Display, EnumString,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum RdapStructure {
Autnum,
Cidr0,
Domain,
DomainSearchResults,
Entity,
EntitySearchResults,
Events,
Error,
Help,
Handle,
HttpData,
IpNetwork,
Link,
Links,
Nameserver,
NameserverSearchResults,
NoticeOrRemark,
Notices,
PublidIds,
Port43,
RdapConformance,
Redacted,
Remarks,
SecureDns,
Status,
}
/// Contains many [CheckItem] structures and sub checks.
///
/// Checks are found on object classes and structures defined in [RdapStructure].
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, PartialOrd, Eq, Ord)]
pub struct Checks {
pub rdap_struct: RdapStructure,
pub items: Vec<CheckItem>,
pub sub_checks: Vec<Checks>,
}
impl Checks {
pub fn sub(&self, rdap_struct: RdapStructure) -> Option<&Self> {
self.sub_checks
.iter()
.find(|check| check.rdap_struct == rdap_struct)
}
}
/// A specific check item.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct CheckItem {
pub check_class: CheckClass,
pub check: Check,
}
impl std::fmt::Display for CheckItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"{}:({:0>4}) {}",
self.check_class,
self.check as usize,
self.check
.get_message()
.unwrap_or("[Check has no description]"),
))
}
}
/// Trait for an item that can get checks.
pub trait GetChecks {
fn get_checks(&self, params: CheckParams) -> Checks;
}
/// Parameters for finding checks.
#[derive(Clone, Copy)]
pub struct CheckParams<'a> {
pub do_subchecks: bool,
pub root: &'a RdapResponse,
pub parent_type: TypeId,
pub allow_unreg_ext: bool,
}
impl CheckParams<'_> {
pub fn from_parent(&self, parent_type: TypeId) -> Self {
Self {
do_subchecks: self.do_subchecks,
root: self.root,
parent_type,
allow_unreg_ext: self.allow_unreg_ext,
}
}
pub fn for_rdap(rdap: &RdapResponse) -> CheckParams<'_> {
CheckParams {
do_subchecks: true,
root: rdap,
parent_type: rdap.get_type(),
allow_unreg_ext: false,
}
}
}
impl GetChecks for RdapResponse {
fn get_checks(&self, params: CheckParams) -> Checks {
match &self {
Self::Entity(e) => e.get_checks(params),
Self::Domain(d) => d.get_checks(params),
Self::Nameserver(n) => n.get_checks(params),
Self::Autnum(a) => a.get_checks(params),
Self::Network(n) => n.get_checks(params),
Self::DomainSearchResults(r) => r.get_checks(params),
Self::EntitySearchResults(r) => r.get_checks(params),
Self::NameserverSearchResults(r) => r.get_checks(params),
Self::ErrorResponse(e) => e.get_checks(params),
Self::Help(h) => h.get_checks(params),
}
}
}
/// Trait to get checks for structures below that of the object class.
pub trait GetSubChecks {
fn get_sub_checks(&self, params: CheckParams) -> Vec<Checks>;
}
/// Traverse the checks, and return true if one is found.
pub fn traverse_checks<F>(
checks: &Checks,
classes: &[CheckClass],
parent_tree: Option<String>,
f: &mut F,
) -> bool
where
F: FnMut(&str, &CheckItem),
{
let mut found = false;
let struct_tree = format!(
"{}/{}",
parent_tree.unwrap_or_else(|| "[ROOT]".to_string()),
checks.rdap_struct
);
for item in &checks.items {
if classes.contains(&item.check_class) {
f(&struct_tree, item);
found = true;
}
}
for sub_checks in &checks.sub_checks {
if traverse_checks(sub_checks, classes, Some(struct_tree.clone()), f) {
found = true
}
}
found
}
/// Returns true if the check is in a check list
pub fn is_checked(check: Check, checks: &[Checks]) -> bool {
checks.iter().any(|c| is_checked_item(check, c))
}
/// Returns true if the check is in a list of check items.
pub fn is_checked_item(check: Check, checks: &Checks) -> bool {
checks.items.iter().any(|c| c.check == check)
}
/// The variant check types.
#[derive(
Debug,
EnumMessage,
EnumString,
Display,
Serialize,
Deserialize,
PartialEq,
PartialOrd,
Eq,
Ord,
Clone,
Copy,
FromRepr,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum Check {
// RDAP Conformance 100 - 199
#[strum(message = "RFC 9083 requires 'rdapConformance' on the root object.")]
RdapConformanceMissing = 100,
#[strum(message = "'rdapConformance' can only appear at the top of response.")]
RdapConformanceInvalidParent = 101,
#[strum(message = "declared extension may not be registered.")]
UnknownExtention = 102,
// Link 200 - 299
#[strum(message = "'value' property not found in Link structure as required by RFC 9083")]
LinkMissingValueProperty = 200,
#[strum(message = "'rel' property not found in Link structure as required by RFC 9083")]
LinkMissingRelProperty = 201,
#[strum(message = "ambiguous follow because related link has no 'type' property")]
LinkRelatedHasNoType = 202,
#[strum(message = "ambiguous follow because related link does not have RDAP media type")]
LinkRelatedIsNotRdap = 203,
#[strum(message = "self link has no 'type' property")]
LinkSelfHasNoType = 204,
#[strum(message = "self link does not have RDAP media type")]
LinkSelfIsNotRdap = 205,
#[strum(message = "RFC 9083 recommends self links for all object classes")]
LinkObjectClassHasNoSelf = 206,
#[strum(message = "'href' property not found in Link structure as required by RFC 9083")]
LinkMissingHrefProperty = 207,
// Domain Variant 300 - 399
#[strum(message = "empty domain variant is ambiguous")]
VariantEmptyDomain = 300,
// Event 400 - 499
#[strum(message = "event date is absent")]
EventDateIsAbsent = 400,
#[strum(message = "event date is not RFC 3339 compliant")]
EventDateIsNotRfc3339 = 401,
#[strum(message = "event action is absent")]
EventActionIsAbsent = 402,
// Notice Or Remark 500 - 599
#[strum(message = "RFC 9083 requires a description in a notice or remark")]
NoticeOrRemarkDescriptionIsAbsent = 500,
#[strum(message = "RFC 9083 requires a description to be an array of strings")]
NoticeOrRemarkDescriptionIsString = 501,
// Handle 600 - 699
#[strum(message = "handle appears to be empty or only whitespace")]
HandleIsEmpty = 600,
// Status 700 - 799
#[strum(message = "status appears to be empty or only whitespace")]
StatusIsEmpty = 700,
// Role 800 - 899
#[strum(message = "role appears to be empty or only whitespace")]
RoleIsEmpty = 800,
#[strum(message = "entity role may not be registered")]
UnknownRole = 801,
#[strum(message = "role is a string, not array of strings")]
RoleIsString = 802,
// LDH Name 900 - 999
#[strum(message = "ldhName does not appear to be an LDH name")]
LdhNameInvalid = 900,
#[strum(message = "Documentation domain name. See RFC 6761")]
LdhNameDocumentation = 901,
#[strum(message = "Unicode name does not match LDH")]
LdhNameDoesNotMatchUnicode = 902,
// Unicode Nmae 1000 - 1099
#[strum(message = "unicodeName does not appear to be a domain name")]
UnicodeNameInvalidDomain = 1000,
#[strum(message = "unicodeName does not appear to be valid Unicode")]
UnicodeNameInvalidUnicode = 1001,
// Network Or Autnum Name 1100 - 1199
#[strum(message = "name appears to be empty or only whitespace")]
NetworkOrAutnumNameIsEmpty = 1100,
// Network or Autnum Type 1200 - 1299
#[strum(message = "type appears to be empty or only whitespace")]
NetworkOrAutnumTypeIsEmpty = 1200,
// IP Address 1300 - 1399
#[strum(message = "start or end IP address is missing")]
IpAddressMissing = 1300,
#[strum(message = "IP address is malformed")]
IpAddressMalformed = 1301,
#[strum(message = "end IP address comes before start IP address")]
IpAddressEndBeforeStart = 1302,
#[strum(message = "IP version does not match IP address")]
IpAddressVersionMismatch = 1303,
#[strum(message = "IP version is malformed")]
IpAddressMalformedVersion = 1304,
#[strum(message = "IP address list is empty")]
IpAddressListIsEmpty = 1305,
#[strum(message = "\"This network.\" See RFC 791")]
IpAddressThisNetwork = 1306,
#[strum(message = "Private use. See RFC 1918")]
IpAddressPrivateUse = 1307,
#[strum(message = "Shared NAT network. See RFC 6598")]
IpAddressSharedNat = 1308,
#[strum(message = "Loopback network. See RFC 1122")]
IpAddressLoopback = 1309,
#[strum(message = "Link local network. See RFC 3927")]
IpAddressLinkLocal = 1310,
#[strum(message = "Unique local network. See RFC 8190")]
IpAddressUniqueLocal = 1311,
#[strum(message = "Documentation network. See RFC 5737")]
IpAddressDocumentationNet = 1312,
#[strum(message = "Reserved network. See RFC 1112")]
IpAddressReservedNet = 1313,
#[strum(message = "IP address array is a string.")]
IpAddressArrayIsString = 1314,
// Autnum 1400 - 1499
#[strum(message = "start or end autnum is missing")]
AutnumMissing = 1400,
#[strum(message = "end AS number comes before start AS number")]
AutnumEndBeforeStart = 1401,
#[strum(message = "Private use. See RFC 6996")]
AutnumPrivateUse = 1402,
#[strum(message = "Documentation AS number. See RFC 5398")]
AutnumDocumentation = 1403,
#[strum(message = "Reserved AS number. See RFC 6996")]
AutnumReserved = 1404,
// Vcard 1500 - 1599
#[strum(message = "vCard array does not contain a vCard")]
VcardArrayIsEmpty = 1500,
#[strum(message = "vCard has no fn property")]
VcardHasNoFn = 1501,
#[strum(message = "vCard fn property is empty")]
VcardFnIsEmpty = 1502,
// Port 43 1600 - 1699
#[strum(message = "port43 appears to be empty or only whitespace")]
Port43IsEmpty = 1600,
// Public Id 1700 - 1799
#[strum(message = "publicId type is absent")]
PublicIdTypeIsAbsent = 1700,
#[strum(message = "publicId identifier is absent")]
PublicIdIdentifierIsAbsent = 1701,
// HTTP 1800 - 1899
#[strum(message = "Use of access-control-allow-origin is recommended.")]
CorsAllowOriginRecommended = 1800,
#[strum(message = "Use of access-control-allow-origin with asterisk is recommended.")]
CorsAllowOriginStarRecommended = 1801,
#[strum(message = "Use of access-control-allow-credentials is not recommended.")]
CorsAllowCredentialsNotRecommended = 1802,
#[strum(message = "No content-type header received.")]
ContentTypeIsAbsent = 1803,
#[strum(message = "Content-type is not application/rdap+json.")]
ContentTypeIsNotRdap = 1804,
// Cidr0 1900 - 1999
#[strum(message = "Cidr0 v4 prefix is absent")]
Cidr0V4PrefixIsAbsent = 1900,
#[strum(message = "Cidr0 v4 length is absent")]
Cidr0V4LengthIsAbsent = 1901,
#[strum(message = "Cidr0 v6 prefix is absent")]
Cidr0V6PrefixIsAbsent = 1902,
#[strum(message = "Cidr0 v6 length is absent")]
Cidr0V6LengthIsAbsent = 1903,
// ICANN Profile 2000 - 2099
#[strum(message = "RDAP Service Must use HTTPS.")]
MustUseHttps = 2000,
#[strum(message = "access-control-allow-origin is not asterisk")]
AllowOriginNotStar = 2001,
// Explicit Testing Errors 2100 - 2199
#[strum(message = "CNAME without A records.")]
CnameWithoutARecords = 2100,
#[strum(message = "CNAME without AAAA records.")]
CnameWithoutAAAARecords = 2101,
#[strum(message = "No A records.")]
NoARecords = 2102,
#[strum(message = "No AAAA records.")]
NoAAAARecords = 2103,
#[strum(message = "Expected extension not found.")]
ExpectedExtensionNotFound = 2104,
#[strum(message = "IPv6 Support Required.")]
Ipv6SupportRequiredByIcann = 2105,
// Secure DNS 2200 - 2299
#[strum(message = "delegationSigned is a string not a bool.")]
DelegationSignedIsString = 2200,
#[strum(message = "zoneSigned is a string not a bool.")]
ZoneSignedIsString = 2201,
#[strum(message = "maxSigLife is a string not a number.")]
MaxSigLifeIsString = 2202,
// key data
#[strum(message = "keyData algorithm is a string not a number.")]
KeyDatumAlgorithmIsString = 2203,
#[strum(message = "keyData algorithm is out of range.")]
KeyDatumAlgorithmIsOutOfRange = 2204,
#[strum(message = "keyData flags is a string not a number.")]
KeyDatumFlagsIsString = 2205,
#[strum(message = "keyData flags is out of range.")]
KeyDatumFlagsIsOutOfRange = 2206,
#[strum(message = "keyData protocol is a string not a number.")]
KeyDatumProtocolIsString = 2207,
#[strum(message = "keyData protocol is out of range.")]
KeyDatumProtocolIsOutOfRange = 2208,
// ds data
#[strum(message = "dsData algorithm is a string not a number.")]
DsDatumAlgorithmIsString = 2213,
#[strum(message = "dsData algorithm is out of range.")]
DsDatumAlgorithmIsOutOfRange = 2214,
#[strum(message = "dsData keyTag is a string not a number.")]
DsDatumKeyTagIsString = 2215,
#[strum(message = "dsData keyTag is out of range.")]
DsDatumKeyTagIsOutOfRange = 2216,
#[strum(message = "dsData digestType is a string not a number.")]
DsDatumDigestTypeIsString = 2217,
#[strum(message = "dsData digestType is out of range.")]
DsDatumDigestTypeIsOutOfRange = 2218,
}
impl Check {
pub fn check_item(self) -> CheckItem {
let check_class = match self {
Self::RdapConformanceMissing | Self::RdapConformanceInvalidParent => {
CheckClass::StdError
}
Self::UnknownExtention => CheckClass::StdWarning,
Self::LinkMissingValueProperty | Self::LinkMissingRelProperty => CheckClass::StdError,
Self::LinkRelatedHasNoType
| Self::LinkRelatedIsNotRdap
| Self::LinkSelfHasNoType
| Self::LinkSelfIsNotRdap => CheckClass::StdWarning,
Self::LinkObjectClassHasNoSelf => CheckClass::SpecificationNote,
Self::LinkMissingHrefProperty => CheckClass::StdError,
Self::VariantEmptyDomain => CheckClass::StdWarning,
Self::EventDateIsAbsent
| Self::EventDateIsNotRfc3339
| Self::EventActionIsAbsent
| Self::NoticeOrRemarkDescriptionIsAbsent
| Self::NoticeOrRemarkDescriptionIsString => CheckClass::StdError,
Self::HandleIsEmpty => CheckClass::StdWarning,
Self::StatusIsEmpty | Self::RoleIsEmpty => CheckClass::StdError,
Self::UnknownRole => CheckClass::StdWarning,
Self::RoleIsString | Self::LdhNameInvalid => CheckClass::StdError,
Self::LdhNameDocumentation => CheckClass::Informational,
Self::LdhNameDoesNotMatchUnicode => CheckClass::StdWarning,
Self::UnicodeNameInvalidDomain | Self::UnicodeNameInvalidUnicode => {
CheckClass::StdError
}
Self::NetworkOrAutnumNameIsEmpty
| Self::NetworkOrAutnumTypeIsEmpty
| Self::IpAddressMissing => CheckClass::StdWarning,
Self::IpAddressMalformed => CheckClass::StdError,
Self::IpAddressEndBeforeStart | Self::IpAddressVersionMismatch => {
CheckClass::StdWarning
}
Self::IpAddressMalformedVersion | Self::IpAddressListIsEmpty => CheckClass::StdError,
Self::IpAddressThisNetwork
| Self::IpAddressPrivateUse
| Self::IpAddressSharedNat
| Self::IpAddressLoopback
| Self::IpAddressLinkLocal
| Self::IpAddressUniqueLocal
| Self::IpAddressDocumentationNet
| Self::IpAddressReservedNet => CheckClass::Informational,
Self::IpAddressArrayIsString => CheckClass::StdError,
Self::AutnumMissing | Self::AutnumEndBeforeStart => CheckClass::StdWarning,
Self::AutnumPrivateUse | Self::AutnumDocumentation | Self::AutnumReserved => {
CheckClass::Informational
}
Self::VcardArrayIsEmpty | Self::VcardHasNoFn => CheckClass::StdError,
Self::VcardFnIsEmpty => CheckClass::SpecificationNote,
Self::Port43IsEmpty | Self::PublicIdTypeIsAbsent | Self::PublicIdIdentifierIsAbsent => {
CheckClass::StdError
}
Self::CorsAllowOriginRecommended
| Self::CorsAllowOriginStarRecommended
| Self::CorsAllowCredentialsNotRecommended => CheckClass::StdWarning,
Self::ContentTypeIsAbsent | Self::ContentTypeIsNotRdap => CheckClass::StdError,
Self::Cidr0V4PrefixIsAbsent
| Self::Cidr0V4LengthIsAbsent
| Self::Cidr0V6PrefixIsAbsent
| Self::Cidr0V6LengthIsAbsent => CheckClass::Cidr0Error,
Self::MustUseHttps | Self::AllowOriginNotStar => CheckClass::IcannError,
Self::CnameWithoutARecords | Self::CnameWithoutAAAARecords => CheckClass::StdError,
Self::NoARecords | Self::NoAAAARecords => CheckClass::SpecificationNote,
Self::ExpectedExtensionNotFound => CheckClass::StdError,
Self::Ipv6SupportRequiredByIcann => CheckClass::IcannError,
Self::DelegationSignedIsString
| Self::ZoneSignedIsString
| Self::MaxSigLifeIsString
| Self::KeyDatumAlgorithmIsString
| Self::KeyDatumAlgorithmIsOutOfRange
| Self::KeyDatumFlagsIsString
| Self::KeyDatumFlagsIsOutOfRange
| Self::KeyDatumProtocolIsString
| Self::KeyDatumProtocolIsOutOfRange
| Self::DsDatumAlgorithmIsString
| Self::DsDatumAlgorithmIsOutOfRange
| Self::DsDatumKeyTagIsString
| Self::DsDatumKeyTagIsOutOfRange
| Self::DsDatumDigestTypeIsString
| Self::DsDatumDigestTypeIsOutOfRange => CheckClass::StdError,
};
CheckItem {
check_class,
check: self,
}
}
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use crate::check::RdapStructure;
use super::{traverse_checks, Check, CheckClass, CheckItem, Checks};
#[test]
fn GIVEN_info_checks_WHEN_traversed_for_info_THEN_found() {
// GIVEN
let checks = Checks {
rdap_struct: RdapStructure::Entity,
items: vec![CheckItem {
check_class: CheckClass::Informational,
check: Check::VariantEmptyDomain,
}],
sub_checks: vec![],
};
// WHEN
let found = traverse_checks(
&checks,
&[CheckClass::Informational],
None,
&mut |struct_tree, check_item| println!("{struct_tree} -> {check_item}"),
);
// THEN
assert!(found);
}
#[test]
fn GIVEN_specwarn_checks_WHEN_traversed_for_info_THEN_not_found() {
// GIVEN
let checks = Checks {
rdap_struct: RdapStructure::Entity,
items: vec![CheckItem {
check_class: CheckClass::StdWarning,
check: Check::VariantEmptyDomain,
}],
sub_checks: vec![],
};
// WHEN
let found = traverse_checks(
&checks,
&[CheckClass::Informational],
None,
&mut |struct_tree, check_item| println!("{struct_tree} -> {check_item}"),
);
// THEN
assert!(!found);
}
#[test]
fn GIVEN_info_subchecks_WHEN_traversed_for_info_THEN_found() {
// GIVEN
let checks = Checks {
rdap_struct: RdapStructure::Entity,
items: vec![],
sub_checks: vec![Checks {
rdap_struct: RdapStructure::Autnum,
items: vec![CheckItem {
check_class: CheckClass::Informational,
check: Check::VariantEmptyDomain,
}],
sub_checks: vec![],
}],
};
// WHEN
let found = traverse_checks(
&checks,
&[CheckClass::Informational],
None,
&mut |struct_tree, check_item| println!("{struct_tree} -> {check_item}"),
);
// THEN
assert!(found);
}
#[test]
fn GIVEN_specwarn_subchecks_WHEN_traversed_for_info_THEN_not_found() {
// GIVEN
let checks = Checks {
rdap_struct: RdapStructure::Entity,
items: vec![],
sub_checks: vec![Checks {
rdap_struct: RdapStructure::Autnum,
items: vec![CheckItem {
check_class: CheckClass::StdWarning,
check: Check::VariantEmptyDomain,
}],
sub_checks: vec![],
}],
};
// WHEN
let found = traverse_checks(
&checks,
&[CheckClass::Informational],
None,
&mut |struct_tree, check_item| println!("{struct_tree} -> {check_item}"),
);
// THEN
assert!(!found);
}
#[test]
fn GIVEN_checks_and_subchecks_WHEN_traversed_THEN_tree_structure_shows_tree() {
// GIVEN
let checks = Checks {
rdap_struct: RdapStructure::Entity,
items: vec![CheckItem {
check_class: CheckClass::Informational,
check: Check::RdapConformanceInvalidParent,
}],
sub_checks: vec![Checks {
rdap_struct: RdapStructure::Autnum,
items: vec![CheckItem {
check_class: CheckClass::Informational,
check: Check::VariantEmptyDomain,
}],
sub_checks: vec![],
}],
};
// WHEN
let mut structs: Vec<String> = vec![];
let found = traverse_checks(
&checks,
&[CheckClass::Informational],
None,
&mut |struct_tree, _check_item| structs.push(struct_tree.to_string()),
);
// THEN
assert!(found);
dbg!(&structs);
assert!(structs.contains(&"[ROOT]/entity".to_string()));
assert!(structs.contains(&"[ROOT]/entity/autnum".to_string()));
}
}

View file

@ -0,0 +1,184 @@
use std::{any::TypeId, net::IpAddr, str::FromStr};
use crate::response::nameserver::Nameserver;
use super::{
string::{StringCheck, StringListCheck},
Check, CheckParams, Checks, GetChecks, GetSubChecks,
};
impl GetChecks for Nameserver {
fn get_checks(&self, params: CheckParams) -> super::Checks {
let sub_checks = if params.do_subchecks {
let mut sub_checks: Vec<Checks> = self
.common
.get_sub_checks(params.from_parent(TypeId::of::<Self>()));
sub_checks.append(
&mut self
.object_common
.get_sub_checks(params.from_parent(TypeId::of::<Self>())),
);
sub_checks
} else {
vec![]
};
let mut items = vec![];
// check ldh
if let Some(ldh) = &self.ldh_name {
if !ldh.is_ldh_domain_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.is_string() {
items.push(Check::IpAddressArrayIsString.check_item())
}
if v6_addrs.is_empty_or_any_empty_or_whitespace() {
items.push(Check::IpAddressListIsEmpty.check_item())
}
if v6_addrs
.vec()
.iter()
.any(|ip| IpAddr::from_str(ip).is_err())
{
items.push(Check::IpAddressMalformed.check_item())
}
}
if let Some(v4_addrs) = &ip_addresses.v4 {
if v4_addrs.is_string() {
items.push(Check::IpAddressArrayIsString.check_item())
}
if v4_addrs.is_empty_or_any_empty_or_whitespace() {
items.push(Check::IpAddressListIsEmpty.check_item())
}
if v4_addrs
.vec()
.iter()
.any(|ip| IpAddr::from_str(ip).is_err())
{
items.push(Check::IpAddressMalformed.check_item())
}
}
}
Checks {
rdap_struct: super::RdapStructure::Nameserver,
items,
sub_checks,
}
}
}
#[cfg(test)]
mod tests {
use {crate::prelude::*, rstest::rstest};
use crate::check::{Check, CheckParams, GetChecks};
#[rstest]
#[case("")]
#[case(" ")]
#[case("_.")]
fn check_nameserver_with_bad_ldh(#[case] ldh: &str) {
// GIVEN
let rdap = Nameserver::builder()
.ldh_name(ldh)
.build()
.unwrap()
.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::LdhNameInvalid));
}
#[test]
fn check_nameserver_with_empty_v6s() {
// GIVEN
let ns = Nameserver::illegal()
.ldh_name("ns1.example.com")
.ip_addresses(IpAddresses::illegal().v6(vec![]).build())
.build()
.to_response();
// WHEN
let checks = ns.get_checks(CheckParams::for_rdap(&ns));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressListIsEmpty));
}
#[test]
fn check_nameserver_with_empty_v4s() {
// GIVEN
let ns = Nameserver::illegal()
.ldh_name("ns1.example.com")
.ip_addresses(IpAddresses::illegal().v4(vec![]).build())
.build()
.to_response();
// WHEN
let checks = ns.get_checks(CheckParams::for_rdap(&ns));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressListIsEmpty));
}
#[test]
fn check_nameserver_with_bad_v6s() {
// GIVEN
let ns = Nameserver::illegal()
.ldh_name("ns1.example.com")
.ip_addresses(IpAddresses::illegal().v6(vec!["__".to_string()]).build())
.build()
.to_response();
// WHEN
let checks = ns.get_checks(CheckParams::for_rdap(&ns));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressMalformed));
}
#[test]
fn check_nameserver_with_bad_v4s() {
// GIVEN
let ns = Nameserver::illegal()
.ldh_name("ns1.example.com")
.ip_addresses(IpAddresses::illegal().v4(vec!["___".to_string()]).build())
.build()
.to_response();
// WHEN
let checks = ns.get_checks(CheckParams::for_rdap(&ns));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressMalformed));
}
}

View file

@ -0,0 +1,482 @@
use std::{any::TypeId, net::IpAddr, str::FromStr};
use cidr::IpCidr;
use crate::response::network::{Cidr0Cidr, Network};
use super::{string::StringCheck, Check, CheckParams, Checks, GetChecks, GetSubChecks};
impl GetChecks for Network {
fn get_checks(&self, params: CheckParams) -> super::Checks {
let sub_checks = if params.do_subchecks {
let mut sub_checks: Vec<Checks> = self
.common
.get_sub_checks(params.from_parent(TypeId::of::<Self>()));
sub_checks.append(
&mut self
.object_common
.get_sub_checks(params.from_parent(TypeId::of::<Self>())),
);
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 {
rdap_struct: super::RdapStructure::Cidr0,
items: vec![Check::Cidr0V4PrefixIsAbsent.check_item()],
sub_checks: vec![],
})
}
if v4.length.is_none() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::Cidr0,
items: vec![Check::Cidr0V4LengthIsAbsent.check_item()],
sub_checks: vec![],
})
}
}
Cidr0Cidr::V6Cidr(v6) => {
if v6.v6prefix.is_none() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::Cidr0,
items: vec![Check::Cidr0V6PrefixIsAbsent.check_item()],
sub_checks: vec![],
})
}
if v6.length.is_none() {
sub_checks.push(Checks {
rdap_struct: super::RdapStructure::Cidr0,
items: vec![Check::Cidr0V6LengthIsAbsent.check_item()],
sub_checks: vec![],
})
}
}
})
}
sub_checks
} else {
vec![]
};
let mut items = vec![];
if let Some(name) = &self.name {
if name.is_whitespace_or_empty() {
items.push(Check::NetworkOrAutnumNameIsEmpty.check_item())
}
}
if let Some(network_type) = &self.network_type {
if network_type.is_whitespace_or_empty() {
items.push(Check::NetworkOrAutnumTypeIsEmpty.check_item())
}
}
if self.start_address.is_none() || self.end_address.is_none() {
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(Check::IpAddressMalformed.check_item())
} else if self.end_address.is_some() {
let Ok(start_addr) = start_addr else {
panic!("ip result did not work")
};
let Some(end_ip) = &self.end_address else {
panic!("end address unwrap failed")
};
if let Ok(end_addr) = IpAddr::from_str(end_ip) {
if start_addr > end_addr {
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(Check::IpAddressVersionMismatch.check_item())
} else if ip_version != "v4" && ip_version != "v6" {
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(Check::IpAddressThisNetwork.check_item())
}
let private_10 = IpCidr::from_str("10.0.0.0/8").expect("incorrect net 10 cidr");
let private_172 =
IpCidr::from_str("172.16.0.0/12").expect("incorrect net 172.16 cidr");
let private_192 =
IpCidr::from_str("192.168.0.0/16").expect("incorrect net 192.168 cidr");
if (private_10.contains(&start_addr) && private_10.contains(&end_addr))
|| (private_172.contains(&start_addr) && private_172.contains(&end_addr))
|| (private_192.contains(&start_addr) && private_192.contains(&end_addr))
{
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(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(Check::IpAddressLoopback.check_item())
}
let linklocal1 =
IpCidr::from_str("169.254.0.0/16").expect("incorrect linklocal1 cidr");
let linklocal2 =
IpCidr::from_str("fe80::/10").expect("incorrect linklocal2 cidr");
if (linklocal1.contains(&start_addr) && linklocal1.contains(&end_addr))
|| (linklocal2.contains(&start_addr) && linklocal2.contains(&end_addr))
{
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(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");
let doc3 = IpCidr::from_str("203.0.113.0/24").expect("incorrect doc3 cidr");
let doc4 = IpCidr::from_str("2001:db8::/32").expect("incorrect doc4 cidr");
if (doc1.contains(&start_addr) && doc1.contains(&end_addr))
|| (doc2.contains(&start_addr) && doc2.contains(&end_addr))
|| (doc3.contains(&start_addr) && doc3.contains(&end_addr))
|| (doc4.contains(&start_addr) && doc4.contains(&end_addr))
{
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(Check::IpAddressLinkLocal.check_item())
}
}
}
}
if let Some(end_ip) = &self.end_address {
let addr = IpAddr::from_str(end_ip);
if addr.is_err() {
items.push(Check::IpAddressMalformed.check_item())
}
}
Checks {
rdap_struct: super::RdapStructure::IpNetwork,
items,
sub_checks,
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use crate::{
prelude::{Numberish, ToResponse},
response::network::{Cidr0Cidr, Network, V4Cidr, V6Cidr},
};
use crate::check::{Check, CheckParams, GetChecks};
#[test]
fn check_network_with_empty_name() {
// GIVEN
let mut network = Network::builder()
.cidr("10.0.0.0/8")
.build()
.expect("invalid ip cidr");
network.name = Some("".to_string());
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::NetworkOrAutnumNameIsEmpty));
}
#[test]
fn check_network_with_empty_type() {
// GIVEN
let mut network = Network::builder()
.cidr("10.0.0.0/8")
.build()
.expect("invalid ip cidr");
network.network_type = Some("".to_string());
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::NetworkOrAutnumTypeIsEmpty));
}
#[test]
fn check_network_with_no_start() {
// GIVEN
let mut network = Network::builder()
.cidr("10.0.0.0/8")
.build()
.expect("invalid ip cidr");
network.start_address = None;
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressMissing));
}
#[test]
fn check_network_with_no_end() {
// GIVEN
let mut network = Network::builder()
.cidr("10.0.0.0/8")
.build()
.expect("invalid ip cidr");
network.end_address = None;
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressMissing));
}
#[test]
fn check_network_with_bad_start() {
// GIVEN
let mut network = Network::builder()
.cidr("10.0.0.0/8")
.build()
.expect("invalid ip cidr");
network.start_address = Some("____".to_string());
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressMalformed));
}
#[test]
fn check_network_with_bad_end() {
// GIVEN
let mut network = Network::builder()
.cidr("10.0.0.0/8")
.build()
.expect("invalid ip cidr");
network.end_address = Some("___".to_string());
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressMalformed));
}
#[test]
fn check_network_with_end_before_start() {
// GIVEN
let mut network = Network::builder()
.cidr("10.0.0.0/8")
.build()
.expect("invalid ip cidr");
let swap = network.end_address.clone();
network.end_address = network.start_address.clone();
network.start_address = swap;
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressEndBeforeStart));
}
#[rstest]
#[case("10.0.0.0/8", "v6")]
#[case("2000::/64", "v4")]
fn check_network_with_ip_version(#[case] cidr: &str, #[case] version: &str) {
// GIVEN
let mut network = Network::builder()
.cidr(cidr)
.build()
.expect("invalid ip cidr");
network.ip_version = Some(version.to_string());
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressVersionMismatch));
}
#[rstest]
#[case("10.0.0.0/8", "__")]
#[case("2000::/64", "__")]
#[case("10.0.0.0/8", "")]
#[case("2000::/64", "")]
fn check_network_with_bad_ip_version(#[case] cidr: &str, #[case] version: &str) {
// GIVEN
let mut network = Network::builder()
.cidr(cidr)
.build()
.expect("invalid ip cidr");
network.ip_version = Some(version.to_string());
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.items
.iter()
.any(|c| c.check == Check::IpAddressMalformedVersion));
}
#[test]
fn check_cidr0_with_v4_prefixex() {
// GIVEN
let network = Network::illegal()
.cidr0_cidrs(vec![Cidr0Cidr::V4Cidr(V4Cidr {
v4prefix: None,
length: Some(Numberish::<u8>::from(0)),
})])
.build();
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.sub(crate::check::RdapStructure::Cidr0)
.expect("Cidr0")
.items
.iter()
.any(|c| c.check == Check::Cidr0V4PrefixIsAbsent));
}
#[test]
fn check_cidr0_with_v6_prefixex() {
// GIVEN
let network = Network::illegal()
.cidr0_cidrs(vec![Cidr0Cidr::V6Cidr(V6Cidr {
v6prefix: None,
length: Some(Numberish::<u8>::from(0)),
})])
.build();
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.sub(crate::check::RdapStructure::Cidr0)
.expect("Cidr0")
.items
.iter()
.any(|c| c.check == Check::Cidr0V6PrefixIsAbsent));
}
#[test]
fn check_cidr0_with_v4_length() {
// GIVEN
let network = Network::illegal()
.cidr0_cidrs(vec![Cidr0Cidr::V4Cidr(V4Cidr {
v4prefix: Some("0.0.0.0".to_string()),
length: None,
})])
.build();
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.sub(crate::check::RdapStructure::Cidr0)
.expect("Cidr0")
.items
.iter()
.any(|c| c.check == Check::Cidr0V4LengthIsAbsent));
}
#[test]
fn check_cidr0_with_v6_length() {
// GIVEN
let network = Network::illegal()
.cidr0_cidrs(vec![Cidr0Cidr::V6Cidr(V6Cidr {
v6prefix: Some("0.0.0.0".to_string()),
length: None,
})])
.build();
let rdap = network.to_response();
// WHEN
let checks = rdap.get_checks(CheckParams::for_rdap(&rdap));
// THEN
dbg!(&checks);
assert!(checks
.sub(crate::check::RdapStructure::Cidr0)
.expect("Cidr0")
.items
.iter()
.any(|c| c.check == Check::Cidr0V6LengthIsAbsent));
}
}

View file

@ -0,0 +1,68 @@
use std::any::TypeId;
use crate::response::search::{DomainSearchResults, EntitySearchResults, NameserverSearchResults};
use super::{CheckParams, Checks, GetChecks, GetSubChecks};
impl GetChecks for DomainSearchResults {
fn get_checks(&self, params: CheckParams) -> super::Checks {
let sub_checks: Vec<Checks> = if params.do_subchecks {
let mut sub_checks = self
.common
.get_sub_checks(params.from_parent(TypeId::of::<Self>()));
self.results.iter().for_each(|result| {
sub_checks.push(result.get_checks(params.from_parent(TypeId::of::<Self>())))
});
sub_checks
} else {
vec![]
};
Checks {
rdap_struct: super::RdapStructure::DomainSearchResults,
items: vec![],
sub_checks,
}
}
}
impl GetChecks for NameserverSearchResults {
fn get_checks(&self, params: CheckParams) -> super::Checks {
let sub_checks: Vec<Checks> = if params.do_subchecks {
let mut sub_checks: Vec<Checks> = self
.common
.get_sub_checks(params.from_parent(TypeId::of::<Self>()));
self.results.iter().for_each(|result| {
sub_checks.push(result.get_checks(params.from_parent(TypeId::of::<Self>())))
});
sub_checks
} else {
vec![]
};
Checks {
rdap_struct: super::RdapStructure::NameserverSearchResults,
items: vec![],
sub_checks,
}
}
}
impl GetChecks for EntitySearchResults {
fn get_checks(&self, params: CheckParams) -> super::Checks {
let sub_checks: Vec<Checks> = if params.do_subchecks {
let mut sub_checks: Vec<Checks> = self
.common
.get_sub_checks(params.from_parent(TypeId::of::<Self>()));
self.results.iter().for_each(|result| {
sub_checks.push(result.get_checks(params.from_parent(TypeId::of::<Self>())))
});
sub_checks
} else {
vec![]
};
Checks {
rdap_struct: super::RdapStructure::EntitySearchResults,
items: vec![],
sub_checks,
}
}
}

View file

@ -0,0 +1,295 @@
/// Functions for types that can be turned into strings.
///
/// Example:
/// ```rust
/// use icann_rdap_common::check::*;
///
/// let s = " ";
/// assert!(s.is_whitespace_or_empty());
/// ```
pub trait StringCheck {
/// Tests if the string is empty, including for if the string only has whitespace.
fn is_whitespace_or_empty(&self) -> bool;
/// Tests if the string contains only letters, digits, or hyphens and is not empty.
fn is_ldh_string(&self) -> bool;
/// Tests if a string is an LDH doamin name. This is not to be confused with [StringCheck::is_ldh_string],
/// which checks individual domain labels.
fn is_ldh_domain_name(&self) -> bool;
/// Tests if a string is a Unicode domain name.
fn is_unicode_domain_name(&self) -> bool;
/// Tests if a string is begins with a period and only has one label.
fn is_tld(&self) -> bool;
}
impl<T: ToString> StringCheck for T {
fn is_whitespace_or_empty(&self) -> bool {
let s = self.to_string();
s.is_empty() || s.chars().all(char::is_whitespace)
}
fn is_ldh_string(&self) -> bool {
let s = self.to_string();
!s.is_empty() && s.chars().all(char::is_ldh)
}
fn is_ldh_domain_name(&self) -> bool {
let s = self.to_string();
s == "." || (!s.is_empty() && s.split_terminator('.').all(|s| s.is_ldh_string()))
}
fn is_unicode_domain_name(&self) -> bool {
let s = self.to_string();
s == "."
|| (!s.is_empty()
&& s.split_terminator('.').all(|s| {
s.chars()
.all(|c| c == '-' || (!c.is_ascii_punctuation() && !c.is_whitespace()))
}))
}
fn is_tld(&self) -> bool {
let s = self.to_string();
s.starts_with('.')
&& s.len() > 2
&& s.matches('.').count() == 1
&& s.split_terminator('.').all(|s| {
s.chars()
.all(|c| !c.is_ascii_punctuation() && !c.is_whitespace())
})
}
}
/// Functions for types that can be turned into arrays of strings.
///
/// Example:
/// ```rust
/// use icann_rdap_common::check::*;
///
/// let a: &[&str] = &["foo",""];
/// assert!(a.is_empty_or_any_empty_or_whitespace());
/// ```
pub trait StringListCheck {
/// Tests if a list of strings is empty, or if any of the
/// elemeents of the list are empty or whitespace.
fn is_empty_or_any_empty_or_whitespace(&self) -> bool;
/// Tests if a list of strings ard LDH strings. See [CharCheck::is_ldh].
fn is_ldh_string_list(&self) -> bool;
}
impl<T: ToString> StringListCheck for &[T] {
fn is_empty_or_any_empty_or_whitespace(&self) -> bool {
self.is_empty() || self.iter().any(|s| s.to_string().is_whitespace_or_empty())
}
fn is_ldh_string_list(&self) -> bool {
!self.is_empty() && self.iter().all(|s| s.to_string().is_ldh_string())
}
}
impl<T: ToString> StringListCheck for Vec<T> {
fn is_empty_or_any_empty_or_whitespace(&self) -> bool {
self.is_empty() || self.iter().any(|s| s.to_string().is_whitespace_or_empty())
}
fn is_ldh_string_list(&self) -> bool {
!self.is_empty() && self.iter().all(|s| s.to_string().is_ldh_string())
}
}
/// Functions for chars.
///
/// Example:
/// ```rust
/// use icann_rdap_common::check::*;
///
/// let c = 'a';
/// assert!(c.is_ldh());
/// ```
pub trait CharCheck {
/// Checks if the character is a letter, digit or a hyphen
#[allow(clippy::wrong_self_convention)]
fn is_ldh(self) -> bool;
}
impl CharCheck for char {
fn is_ldh(self) -> bool {
matches!(self, 'A'..='Z' | 'a'..='z' | '0'..='9' | '-')
}
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use rstest::rstest;
use crate::check::string::{CharCheck, StringListCheck};
use super::StringCheck;
#[rstest]
#[case("foo", false)]
#[case("", true)]
#[case(" ", true)]
#[case("foo bar", false)]
fn GIVEN_string_WHEN_is_whitespace_or_empty_THEN_correct_result(
#[case] test_string: &str,
#[case] expected: bool,
) {
// GIVEN in parameters
// WHEN
let actual = test_string.is_whitespace_or_empty();
// THEN
assert_eq!(actual, expected);
}
#[rstest]
#[case(&[], true)]
#[case(&["foo"], false)]
#[case(&["foo",""], true)]
#[case(&["foo","bar"], false)]
#[case(&["foo","bar baz"], false)]
#[case(&[""], true)]
#[case(&[" "], true)]
fn GIVEN_string_list_WHEN_is_whitespace_or_empty_THEN_correct_result(
#[case] test_list: &[&str],
#[case] expected: bool,
) {
// GIVEN in parameters
// WHEN
let actual = test_list.is_empty_or_any_empty_or_whitespace();
// THEN
assert_eq!(actual, expected);
}
#[rstest]
#[case('a', true)]
#[case('l', true)]
#[case('z', true)]
#[case('A', true)]
#[case('L', true)]
#[case('Z', true)]
#[case('0', true)]
#[case('3', true)]
#[case('9', true)]
#[case('-', true)]
#[case('_', false)]
#[case('.', false)]
fn GIVEN_char_WHEN_is_ldh_THEN_correct_result(#[case] test_char: char, #[case] expected: bool) {
// GIVEN in parameters
// WHEN
let actual = test_char.is_ldh();
// THEN
assert_eq!(actual, expected);
}
#[rstest]
#[case("foo", true)]
#[case("", false)]
#[case("foo-bar", true)]
#[case("foo bar", false)]
fn GIVEN_string_WHEN_is_ldh_string_THEN_correct_result(
#[case] test_string: &str,
#[case] expected: bool,
) {
// GIVEN in parameters
// WHEN
let actual = test_string.is_ldh_string();
// THEN
assert_eq!(actual, expected);
}
#[rstest]
#[case("foo", false)]
#[case("", false)]
#[case("foo-bar", false)]
#[case("foo bar", false)]
#[case(".", false)]
#[case(".foo.bar", false)]
#[case(".foo", true)]
fn GIVEN_string_WHEN_is_tld_THEN_correct_result(
#[case] test_string: &str,
#[case] expected: bool,
) {
// GIVEN in parameters
// WHEN
let actual = test_string.is_tld();
// THEN
assert_eq!(actual, expected);
}
#[rstest]
#[case(&[], false)]
#[case(&["foo"], true)]
#[case(&["foo",""], false)]
#[case(&["foo","bar"], true)]
#[case(&["foo","bar baz"], false)]
#[case(&[""], false)]
#[case(&[" "], false)]
fn GIVEN_string_list_WHEN_is_ldh_string_list_THEN_correct_result(
#[case] test_list: &[&str],
#[case] expected: bool,
) {
// GIVEN in parameters
// WHEN
let actual = test_list.is_ldh_string_list();
// THEN
assert_eq!(actual, expected);
}
#[rstest]
#[case("foo", true)]
#[case("", false)]
#[case(".", true)]
#[case("foo.bar", true)]
#[case("foo.bar.", true)]
fn GIVEN_string_WHEN_is_ldh_domain_name_THEN_correct_result(
#[case] test_string: &str,
#[case] expected: bool,
) {
// GIVEN in parameters
// WHEN
let actual = test_string.is_ldh_domain_name();
// THEN
assert_eq!(actual, expected);
}
#[rstest]
#[case("foo", true)]
#[case("", false)]
#[case(".", true)]
#[case("foo.bar", true)]
#[case("foo.bar.", true)]
#[case("fo_o.bar.", false)]
#[case("fo o.bar.", false)]
fn GIVEN_string_WHEN_is_unicode_domain_name_THEN_correct_result(
#[case] test_string: &str,
#[case] expected: bool,
) {
// GIVEN in parameters
// WHEN
let actual = test_string.is_unicode_domain_name();
// THEN
assert_eq!(actual, expected);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,846 @@
//! 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<Value> = serde_json::from_str(json).unwrap();
/// let contact = Contact::from_vcard(&data);
/// ```
pub fn from_vcard(vcard_array: &[Value]) -> Option<Contact> {
// value should be "vcard" followed by array
let value = vcard_array.first()?;
let vcard_literal = value.as_str()?;
if !vcard_literal.eq_ignore_ascii_case("vcard") {
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())
.titles(vcard.find_properties("title").get_texts().unwrap_or(vec![]))
.roles(vcard.find_properties("role").get_texts().unwrap_or(vec![]))
.nick_names(
vcard
.find_properties("nickname")
.get_texts()
.unwrap_or(vec![]),
)
.organization_names(vcard.find_properties("org").get_texts().unwrap_or(vec![]))
.langs(vcard.find_properties("lang").get_langs().unwrap_or(vec![]))
.emails(
vcard
.find_properties("email")
.get_emails()
.unwrap_or(vec![]),
)
.phones(vcard.find_properties("tel").get_phones().unwrap_or(vec![]))
.postal_addresses(
vcard
.find_properties("adr")
.get_postal_addresses()
.unwrap_or(vec![]),
)
.and_name_parts(vcard.find_property("n").get_name_parts())
.contact_uris(
vcard
.find_properties("contact-uri")
.get_texts()
.unwrap_or(vec![]),
)
.urls(vcard.find_properties("url").get_texts().unwrap_or(vec![]))
.build();
contact.is_non_empty().then_some(contact)
}
}
trait FindProperty<'a> {
fn find_property(self, name: &'a str) -> Option<&'a Vec<Value>>;
}
impl<'a> FindProperty<'a> for &'a [Value] {
fn find_property(self, name: &'a str) -> Option<&'a Vec<Value>> {
self.iter()
.filter_map(|prop_array| prop_array.as_array())
.find(|prop_array| {
if let Some(prop_name) = prop_array.first() {
if let Some(prop_name) = prop_name.as_str() {
prop_name.eq_ignore_ascii_case(name)
} else {
false
}
} else {
false
}
})
}
}
trait FindProperties<'a> {
fn find_properties(self, name: &'a str) -> Vec<&'a Vec<Value>>;
}
impl<'a> FindProperties<'a> for &'a [Value] {
fn find_properties(self, name: &'a str) -> Vec<&'a Vec<Value>> {
self.iter()
.filter_map(|prop_array| prop_array.as_array())
.filter(|prop_array| {
if let Some(prop_name) = prop_array.first() {
if let Some(prop_name) = prop_name.as_str() {
prop_name.eq_ignore_ascii_case(name)
} else {
false
}
} else {
false
}
})
.collect()
}
}
trait GetText<'a> {
fn get_text(self) -> Option<String>;
}
impl<'a> GetText<'a> for Option<&'a Vec<Value>> {
fn get_text(self) -> Option<String> {
let values = self?;
let fourth = values.get(3)?;
fourth.as_str().map(|s| s.to_owned())
}
}
impl<'a> GetText<'a> for &'a Vec<Value> {
fn get_text(self) -> Option<String> {
let fourth = self.get(3)?;
fourth.as_str().map(|s| s.to_owned())
}
}
trait GetTexts<'a> {
fn get_texts(self) -> Option<Vec<String>>;
}
impl<'a> GetTexts<'a> for &'a [&'a Vec<Value>] {
fn get_texts(self) -> Option<Vec<String>> {
let texts = self
.iter()
.filter_map(|prop| (*prop).get_text())
.collect::<Vec<String>>();
(!texts.is_empty()).then_some(texts)
}
}
trait GetPreference<'a> {
fn get_preference(self) -> Option<u64>;
}
impl<'a> GetPreference<'a> for &'a Vec<Value> {
fn get_preference(self) -> Option<u64> {
let second = self.get(1)?;
let second = second.as_object()?;
let preference = second.get("pref")?;
preference.as_str().and_then(|s| s.parse().ok())
}
}
trait GetLabel<'a> {
fn get_label(self) -> Option<String>;
}
impl<'a> GetLabel<'a> for &'a Vec<Value> {
fn get_label(self) -> Option<String> {
let second = self.get(1)?;
let second = second.as_object()?;
let label = second.get("label")?;
label.as_str().map(|s| s.to_owned())
}
}
const CONTEXTS: [&str; 6] = ["home", "work", "office", "private", "mobile", "cell"];
trait GetContexts<'a> {
fn get_contexts(self) -> Option<Vec<String>>;
}
impl<'a> GetContexts<'a> for &'a Vec<Value> {
fn get_contexts(self) -> Option<Vec<String>> {
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()) {
return Some(vec![context]);
} else {
return None;
}
};
let contexts = contexts.as_array()?;
let contexts = contexts
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_lowercase())
.filter(|s| CONTEXTS.contains(&s.as_str()))
.collect::<Vec<String>>();
(!contexts.is_empty()).then_some(contexts)
}
}
trait GetFeatures<'a> {
fn get_features(self) -> Option<Vec<String>>;
}
impl<'a> GetFeatures<'a> for &'a Vec<Value> {
fn get_features(self) -> Option<Vec<String>> {
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()) {
return Some(vec![feature]);
} else {
return None;
}
};
let features = features.as_array()?;
let features = features
.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_lowercase())
.filter(|s| !CONTEXTS.contains(&s.as_str()))
.collect::<Vec<String>>();
(!features.is_empty()).then_some(features)
}
}
trait GetLangs<'a> {
fn get_langs(self) -> Option<Vec<Lang>>;
}
impl<'a> GetLangs<'a> for &'a [&'a Vec<Value>] {
fn get_langs(self) -> Option<Vec<Lang>> {
let langs = self
.iter()
.filter_map(|prop| {
let tag = (*prop).get_text()?;
let lang = Lang::builder()
.tag(tag)
.and_preference((*prop).get_preference())
.build();
Some(lang)
})
.collect::<Vec<Lang>>();
(!langs.is_empty()).then_some(langs)
}
}
trait GetEmails<'a> {
fn get_emails(self) -> Option<Vec<Email>>;
}
impl<'a> GetEmails<'a> for &'a [&'a Vec<Value>] {
fn get_emails(self) -> Option<Vec<Email>> {
let emails = self
.iter()
.filter_map(|prop| {
let addr = (*prop).get_text()?;
let email = Email::builder()
.email(addr)
.contexts((*prop).get_contexts().unwrap_or_default())
.and_preference((*prop).get_preference())
.build();
Some(email)
})
.collect::<Vec<Email>>();
(!emails.is_empty()).then_some(emails)
}
}
trait GetPhones<'a> {
fn get_phones(self) -> Option<Vec<Phone>>;
}
impl<'a> GetPhones<'a> for &'a [&'a Vec<Value>] {
fn get_phones(self) -> Option<Vec<Phone>> {
let phones = self
.iter()
.filter_map(|prop| {
let number = (*prop).get_text()?;
let phone = Phone::builder()
.phone(number)
.features((*prop).get_features().unwrap_or_default())
.contexts((*prop).get_contexts().unwrap_or_default())
.and_preference((*prop).get_preference())
.build();
Some(phone)
})
.collect::<Vec<Phone>>();
(!phones.is_empty()).then_some(phones)
}
}
trait GetPostalAddresses<'a> {
fn get_postal_addresses(self) -> Option<Vec<PostalAddress>>;
}
impl<'a> GetPostalAddresses<'a> for &'a [&'a Vec<Value>] {
fn get_postal_addresses(self) -> Option<Vec<PostalAddress>> {
let addrs = self
.iter()
.map(|prop| {
let mut postal_code: Option<String> = None;
let mut country_code: Option<String> = None;
let mut country_name: Option<String> = None;
let mut region_code: Option<String> = None;
let mut region_name: Option<String> = None;
let mut locality: Option<String> = None;
let mut street_parts: Vec<String> = vec![];
if let Some(fourth) = prop.get(3) {
if let Some(addr) = fourth.as_array() {
// 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(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())
}
}
}
}
}
};
let street_parts = (!street_parts.is_empty()).then_some(street_parts);
PostalAddress::builder()
.and_full_address((*prop).get_label())
.contexts((*prop).get_contexts().unwrap_or_default())
.and_preference((*prop).get_preference())
.and_country_code(country_code)
.and_country_name(country_name)
.and_postal_code(postal_code)
.and_region_name(region_name)
.and_region_code(region_code)
.and_locality(locality)
.street_parts(street_parts.unwrap_or_default())
.build()
})
.collect::<Vec<PostalAddress>>();
(!addrs.is_empty()).then_some(addrs)
}
}
trait GetNameParts<'a> {
fn get_name_parts(self) -> Option<NameParts>;
}
impl<'a> GetNameParts<'a> for Option<&'a Vec<Value>> {
fn get_name_parts(self) -> Option<NameParts> {
let values = self?;
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<Vec<String>> = None;
let mut surnames: Option<Vec<String>> = None;
let mut given_names: Option<Vec<String>> = None;
let mut middle_names: Option<Vec<String>> = None;
let mut suffixes: Option<Vec<String>> = None;
if let Some(e) = iter.next() {
surnames = get_string_or_vec(e);
};
if let Some(e) = iter.next() {
given_names = get_string_or_vec(e);
};
if let Some(e) = iter.next() {
middle_names = get_string_or_vec(e);
};
if let Some(e) = iter.next() {
prefixes = get_string_or_vec(e);
};
if let Some(e) = iter.next() {
suffixes = get_string_or_vec(e);
};
let name_parts = NameParts::builder()
.surnames(surnames.unwrap_or_default())
.prefixes(prefixes.unwrap_or_default())
.given_names(given_names.unwrap_or_default())
.middle_names(middle_names.unwrap_or_default())
.suffixes(suffixes.unwrap_or_default())
.build();
if name_parts.surnames.is_none()
&& name_parts.given_names.is_none()
&& name_parts.middle_names.is_none()
&& name_parts.suffixes.is_none()
&& name_parts.prefixes.is_none()
{
None
} else {
Some(name_parts)
}
}
}
fn get_string_or_vec(value: &Value) -> Option<Vec<String>> {
if let Some(e) = value.as_str() {
if e.is_empty() {
return None;
} else {
return Some(vec![e.to_string()]);
};
};
if let Some(e) = value.as_array() {
let strings = e
.iter()
.filter_map(|e| e.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect::<Vec<String>>();
return (!strings.is_empty()).then_some(strings);
};
None
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use serde_json::Value;
use crate::contact::{Contact, NameParts};
#[test]
fn GIVEN_vcard_WHEN_from_vcard_THEN_properties_are_correct() {
// GIVEN
let vcard = r#"
[
"vcard",
[
["version", {}, "text", "4.0"],
["fn", {}, "text", "Joe User"],
["n", {}, "text",
["User", "Joe", "", "", ["ing. jr", "M.Sc."]]
],
["kind", {}, "text", "individual"],
["lang", {
"pref":"1"
}, "language-tag", "fr"],
["lang", {
"pref":"2"
}, "language-tag", "en"],
["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"
]
],
["adr",
{
"type":"home",
"label":"123 Maple Ave\nSuite 90001\nVancouver\nBC\n1239\n"
},
"text",
[
"", "", "", "", "", "", ""
]
],
["tel",
{
"type":["work", "voice"],
"pref":"1"
},
"uri",
"tel:+1-555-555-1234;ext=102"
],
["tel",
{ "type":["work", "cell", "voice", "video", "text"] },
"uri",
"tel:+1-555-555-4321"
],
["email",
{ "type":"work" },
"text",
"joe.user@example.com"
],
["geo", {
"type":"work"
}, "uri", "geo:46.772673,-71.282945"],
["key",
{ "type":"work" },
"uri",
"https://www.example.com/joe.user/joe.asc"
],
["tz", {},
"utc-offset", "-05:00"],
["contact-uri", {},
"uri",
"https://example.com/contact-form"
],
["url", {},
"uri",
"https://example.com/some-url"
]
]
]
"#;
// WHEN
let actual = serde_json::from_str::<Vec<Value>>(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");
// kind
assert_eq!(actual.kind.expect("kind not found"), "individual");
// titles
assert_eq!(
actual
.titles
.expect("no titles")
.first()
.expect("titles empty"),
"Research Scientist"
);
// roles
assert_eq!(
actual
.roles
.expect("no roles")
.first()
.expect("roles empty"),
"Project Lead"
);
// organization names
assert_eq!(
actual
.organization_names
.expect("no organization_names")
.first()
.expect("organization_names empty"),
"Example"
);
// nick names
assert!(actual.nick_names.is_none());
// langs
let Some(langs) = actual.langs else {
panic!("langs not found")
};
assert_eq!(langs.len(), 2);
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));
// emails
let Some(emails) = actual.emails else {
panic!("emails not found")
};
let Some(email) = emails.first() else {
panic!("no email found")
};
assert_eq!(email.email, "joe.user@example.com");
assert!(email
.contexts
.as_ref()
.expect("contexts not found")
.contains(&"work".to_string()));
// phones
let Some(phones) = actual.phones else {
panic!("no phones found")
};
let Some(phone) = phones.first() else {
panic!("no first phone")
};
assert_eq!(phone.phone, "tel:+1-555-555-1234;ext=102");
assert!(phone
.contexts
.as_ref()
.expect("no contexts")
.contains(&"work".to_string()));
assert!(phone
.features
.as_ref()
.expect("no features")
.contains(&"voice".to_string()));
let Some(phone) = phones.last() else {
panic!("no last phone")
};
assert_eq!(phone.phone, "tel:+1-555-555-4321");
assert!(phone
.contexts
.as_ref()
.expect("no contexts")
.contains(&"cell".to_string()));
assert!(phone
.features
.as_ref()
.expect("no features")
.contains(&"video".to_string()));
// 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 Somewhere"
);
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");
let Some(addr) = addresses.last() else {
panic!("last address not found")
};
assert!(addr
.contexts
.as_ref()
.expect("no contexts")
.contains(&"home".to_string()));
assert_eq!(
addr.full_address.as_ref().expect("full address not foudn"),
"123 Maple Ave\nSuite 90001\nVancouver\nBC\n1239\n"
);
// name parts
let Some(name_parts) = actual.name_parts else {
panic!("no name parts")
};
let expected = NameParts::builder()
.surnames(vec!["User".to_string()])
.given_names(vec!["Joe".to_string()])
.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::<Vec<Value>>(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");
}
}

View file

@ -0,0 +1,486 @@
//! 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<Value> = serde_json::from_str(json).unwrap();
//! let contact = Contact::from_vcard(&data);
//! ```
mod from_vcard;
mod to_vcard;
use std::fmt::Display;
use buildstructor::Builder;
use crate::prelude::to_opt_vec;
/// Represents a contact. This more closely represents an EPP Contact with some
/// things taken from JSContact.
///
/// Using the builder to create the Contact:
/// ```rust
/// use icann_rdap_common::contact::Contact;
///
/// let contact = Contact::builder()
/// .kind("individual")
/// .full_name("Bob Smurd")
/// .build();
/// ```
///
///
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Contact {
/// Preferred languages.
pub langs: Option<Vec<Lang>>,
/// The kind such as individual, company, etc...
pub kind: Option<String>,
/// Full name of the contact.
pub full_name: Option<String>,
/// Structured parts of the name.
pub name_parts: Option<NameParts>,
/// Nick names.
pub nick_names: Option<Vec<String>>,
/// Titles.
pub titles: Option<Vec<String>>,
/// Organizational Roles
pub roles: Option<Vec<String>>,
/// Organization names.
pub organization_names: Option<Vec<String>>,
/// Postal addresses.
pub postal_addresses: Option<Vec<PostalAddress>>,
/// Email addresses.
pub emails: Option<Vec<Email>>,
/// Phone numbers.
pub phones: Option<Vec<Phone>>,
/// Contact URIs.
pub contact_uris: Option<Vec<String>>,
/// URLs
pub urls: Option<Vec<String>>,
}
#[buildstructor::buildstructor]
impl Contact {
#[builder(visibility = "pub")]
#[allow(clippy::too_many_arguments)]
fn new(
langs: Vec<Lang>,
kind: Option<String>,
full_name: Option<String>,
name_parts: Option<NameParts>,
nick_names: Vec<String>,
titles: Vec<String>,
roles: Vec<String>,
organization_names: Vec<String>,
postal_addresses: Vec<PostalAddress>,
emails: Vec<Email>,
phones: Vec<Phone>,
contact_uris: Vec<String>,
urls: Vec<String>,
) -> Self {
Self {
langs: to_opt_vec(langs),
kind,
full_name,
name_parts,
nick_names: to_opt_vec(nick_names),
titles: to_opt_vec(titles),
roles: to_opt_vec(roles),
organization_names: to_opt_vec(organization_names),
postal_addresses: to_opt_vec(postal_addresses),
emails: to_opt_vec(emails),
phones: to_opt_vec(phones),
contact_uris: to_opt_vec(contact_uris),
urls: to_opt_vec(urls),
}
}
/// Returns false if there is data in the Contact.
pub fn is_non_empty(&self) -> bool {
self.langs.is_some()
|| self.kind.is_some()
|| self.full_name.is_some()
|| 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<Email> = emails
.iter()
.map(|e| Email::builder().email(e.to_string()).build())
.collect();
self.emails = (!emails.is_empty()).then_some(emails);
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<Phone> = phones
.iter()
.map(|p| {
Phone::builder()
.contexts(vec!["voice".to_string()])
.phone(p.to_string())
.build()
})
.collect();
if let Some(mut self_phones) = self.phones.clone() {
phones.append(&mut self_phones);
} else {
self.phones = (!phones.is_empty()).then_some(phones);
}
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<Phone> = phones
.iter()
.map(|p| {
Phone::builder()
.contexts(vec!["fax".to_string()])
.phone(p.to_string())
.build()
})
.collect();
if let Some(mut self_phones) = self.phones.clone() {
phones.append(&mut self_phones);
} else {
self.phones = (!phones.is_empty()).then_some(phones);
}
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
}
}
/// The language preference of the contact.
#[derive(Debug, Builder, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Lang {
/// The ordinal of the preference for this language.
pub preference: Option<u64>,
/// RFC 5646 language tag.
pub tag: String,
}
impl Display for Lang {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(pref) = self.preference {
write!(f, "{} (pref: {})", self.tag, pref)
} else {
f.write_str(&self.tag)
}
}
}
/// Name parts of a name.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NameParts {
/// Name prefixes.
pub prefixes: Option<Vec<String>>,
/// Surnames or last names.
pub surnames: Option<Vec<String>>,
/// Middle names.
pub middle_names: Option<Vec<String>>,
/// Given or first names.
pub given_names: Option<Vec<String>>,
/// Name suffixes.
pub suffixes: Option<Vec<String>>,
}
#[buildstructor::buildstructor]
impl NameParts {
#[builder(visibility = "pub")]
#[allow(clippy::too_many_arguments)]
fn new(
prefixes: Vec<String>,
surnames: Vec<String>,
middle_names: Vec<String>,
given_names: Vec<String>,
suffixes: Vec<String>,
) -> Self {
Self {
prefixes: to_opt_vec(prefixes),
surnames: to_opt_vec(surnames),
middle_names: to_opt_vec(middle_names),
given_names: to_opt_vec(given_names),
suffixes: to_opt_vec(suffixes),
}
}
}
/// A postal address.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct PostalAddress {
/// Preference of this address in relation to others.
pub preference: Option<u64>,
/// Work, home, etc.... Known as "type" in JCard.
pub contexts: Option<Vec<String>>,
/// 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<String>,
/// Invidual street lines.
pub street_parts: Option<Vec<String>>,
/// City name, county name, etc...
pub locality: Option<String>,
/// Name of region (i.e. state, province, etc...).
pub region_name: Option<String>,
/// Code for region.
pub region_code: Option<String>,
/// Name of the country.
pub country_name: Option<String>,
/// Code of the country.
pub country_code: Option<String>,
/// Postal code.
pub postal_code: Option<String>,
}
#[buildstructor::buildstructor]
impl PostalAddress {
#[builder(visibility = "pub")]
#[allow(clippy::too_many_arguments)]
fn new(
preference: Option<u64>,
contexts: Vec<String>,
full_address: Option<String>,
street_parts: Vec<String>,
locality: Option<String>,
region_name: Option<String>,
region_code: Option<String>,
country_name: Option<String>,
country_code: Option<String>,
postal_code: Option<String>,
) -> Self {
Self {
preference,
contexts: to_opt_vec(contexts),
full_address,
street_parts: to_opt_vec(street_parts),
locality,
region_name,
region_code,
country_name,
country_code,
postal_code,
}
}
}
/// Represents an email address.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Email {
/// Preference of this email in relation to others.
pub preference: Option<u64>,
/// Work, home, etc.... Known as "type" in JCard.
pub contexts: Option<Vec<String>>,
/// The email address.
pub email: String,
}
#[buildstructor::buildstructor]
impl Email {
#[builder(visibility = "pub")]
fn new(preference: Option<u64>, contexts: Vec<String>, email: String) -> Self {
Self {
preference,
contexts: to_opt_vec(contexts),
email,
}
}
}
impl Display for Email {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut qualifiers = vec![];
if let Some(pref) = self.preference {
qualifiers.push(format!("(pref: {pref})"));
}
if let Some(contexts) = &self.contexts {
qualifiers.push(format!("({})", contexts.join(",")));
}
let qualifiers = qualifiers.join(" ");
if qualifiers.is_empty() {
f.write_str(&self.email)
} else {
write!(f, "{} {}", &self.email, qualifiers)
}
}
}
/// Represents phone number.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Phone {
/// Preference of this phone in relation to others.
pub preference: Option<u64>,
/// Work, home, etc.... Known as "type" in JCard.
pub contexts: Option<Vec<String>>,
/// The phone number.
pub phone: String,
/// Features (voice, fax, etc...)
pub features: Option<Vec<String>>,
}
#[buildstructor::buildstructor]
impl Phone {
#[builder(visibility = "pub")]
fn new(
preference: Option<u64>,
contexts: Vec<String>,
phone: String,
features: Vec<String>,
) -> Self {
Self {
preference,
contexts: to_opt_vec(contexts),
phone,
features: to_opt_vec(features),
}
}
}
impl Display for Phone {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut qualifiers = vec![];
if let Some(pref) = self.preference {
qualifiers.push(format!("(pref: {pref})"));
}
if let Some(contexts) = &self.contexts {
qualifiers.push(format!("({})", contexts.join(",")));
}
if let Some(features) = &self.features {
qualifiers.push(format!("({})", features.join(",")));
}
let qualifiers = qualifiers.join(" ");
if qualifiers.is_empty() {
f.write_str(&self.phone)
} else {
write!(f, "{} {}", &self.phone, qualifiers)
}
}
}

View file

@ -0,0 +1,303 @@
//! Convert a Contact to jCard/vCard.
use std::str::FromStr;
use serde_json::{json, Map, Value};
use super::Contact;
impl Contact {
/// Output the Contact data as vCard in JSON values ([`Vec<Value>`]).
///
/// ```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<Value> {
// start the vcard with the version.
let mut vcard: Vec<Value> = vec![json!(["version", {}, "text", "4.0"])];
if let Some(full_name) = &self.full_name {
vcard.push(json!(["fn", {}, "text", full_name]));
}
if let Some(name_parts) = &self.name_parts {
let surnames = vec_string_to_value(&name_parts.surnames);
let given_names = vec_string_to_value(&name_parts.given_names);
let middle_names = vec_string_to_value(&name_parts.middle_names);
let prefixes = vec_string_to_value(&name_parts.prefixes);
let suffixes = vec_string_to_value(&name_parts.suffixes);
vcard.push(json!([
"n",
{},
"text",
[surnames, given_names, middle_names, prefixes, suffixes]
]));
}
if let Some(kind) = &self.kind {
vcard.push(json!(["kind", {}, "text", kind]));
}
if let Some(langs) = &self.langs {
for lang in langs {
let mut params: Map<String, Value> = Map::new();
if let Some(pref) = lang.preference {
params.insert("pref".to_string(), Value::String(pref.to_string()));
}
vcard.push(json!([
"lang",
Value::from(params),
"language-tag",
lang.tag
]))
}
}
if let Some(org_names) = &self.organization_names {
for org_name in org_names {
vcard.push(json!(["org", {}, "text", org_name]));
}
}
if let Some(titles) = &self.titles {
for title in titles {
vcard.push(json!(["title", {}, "text", title]));
}
}
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]));
}
}
if let Some(emails) = &self.emails {
for email in emails {
let mut params: Map<String, Value> = Map::new();
if let Some(pref) = email.preference {
params.insert("pref".to_string(), Value::String(pref.to_string()));
}
if let Some(contexts) = email.contexts.as_ref() {
params.insert("type".to_string(), vec_string_to_param(contexts));
}
vcard.push(json!(["email", Value::from(params), "text", email.email]))
}
}
if let Some(phones) = &self.phones {
for phone in phones {
let mut params: Map<String, Value> = Map::new();
if let Some(pref) = phone.preference {
params.insert("pref".to_string(), Value::String(pref.to_string()));
}
let mut types: Vec<String> = vec![];
if let Some(contexts) = &phone.contexts {
types.append(&mut contexts.clone());
}
if let Some(features) = &phone.features {
types.append(&mut features.clone());
}
params.insert("type".to_string(), vec_string_to_param(&types));
vcard.push(json!(["tel", Value::from(params), "text", phone.phone]))
}
}
if let Some(addrs) = &self.postal_addresses {
for addr in addrs {
let mut params: Map<String, Value> = Map::new();
if let Some(pref) = addr.preference {
params.insert("pref".to_string(), Value::String(pref.to_string()));
}
if let Some(contexts) = addr.contexts.as_ref() {
params.insert("type".to_string(), vec_string_to_param(contexts));
}
if let Some(full_address) = &addr.full_address {
params.insert(
"label".to_string(),
Value::from_str(full_address).expect("serializing full address"),
);
}
let mut lines: Vec<String> = vec![];
if let Some(street_parts) = &addr.street_parts {
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 {
lines.push("".to_string());
lines.push("".to_string());
lines.push("".to_string());
}
if let Some(locality) = &addr.locality {
lines.push(locality.to_owned());
} else {
lines.push("".to_string());
}
if let Some(region_name) = &addr.region_name {
lines.push(region_name.to_owned());
} else if let Some(region_code) = &addr.region_code {
lines.push(region_code.to_owned());
} else {
lines.push("".to_string());
}
if let Some(postal_code) = &addr.postal_code {
lines.push(postal_code.to_owned());
} else {
lines.push("".to_string());
}
if let Some(country_name) = &addr.country_name {
lines.push(country_name.to_owned());
} else if let Some(country_code) = &addr.country_code {
lines.push(country_code.to_owned());
} else {
lines.push("".to_string());
}
vcard.push(json!(["adr", Value::from(params), "text", lines]))
}
}
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)]
}
}
fn vec_string_to_value(strings: &Option<Vec<String>>) -> Value {
let Some(strings) = strings else {
return Value::String("".to_string());
};
if strings.is_empty() {
return Value::String("".to_string());
};
if strings.len() == 1 {
let Some(one) = strings.first() else {
panic!("couldn't get first element on length of 1")
};
return Value::String(one.to_owned());
};
// else
Value::from(strings.clone())
}
fn vec_string_to_param(strings: &[String]) -> Value {
if strings.is_empty() {
return Value::String("".to_string());
};
if strings.len() == 1 {
let Some(one) = strings.first() else {
panic!("couldn't get first element on length of 1")
};
return Value::String(one.to_owned());
};
// else
Value::from(strings)
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use crate::contact::{Contact, Email, Lang, NameParts, Phone, PostalAddress};
#[test]
fn GIVEN_contact_WHEN_to_vcard_THEN_from_vcard_is_same() {
// GIVEN
let contact = Contact::builder()
.full_name("Joe User")
.name_parts(
NameParts::builder()
.surnames(vec!["User".to_string()])
.given_names(vec!["Joe".to_string()])
.suffixes(vec!["ing. jr".to_string(), "M.Sc.".to_string()])
.build(),
)
.kind("individual")
.langs(vec![
Lang::builder().preference(1).tag("fr").build(),
Lang::builder().preference(2).tag("en").build(),
])
.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")
.region_code("QC")
.locality("Quebec")
.street_parts(vec![
"Suite 1234".to_string(),
"4321 Rue Somewhere".to_string(),
])
.build()])
.phones(vec![
Phone::builder()
.preference(1)
.contexts(vec!["work".to_string()])
.features(vec!["voice".to_string()])
.phone("tel:+1-555-555-1234;ext=102")
.build(),
Phone::builder()
.contexts(vec!["work".to_string(), "cell".to_string()])
.features(vec![
"voice".to_string(),
"video".to_string(),
"text".to_string(),
])
.phone("tel:+1-555-555-4321")
.build(),
])
.emails(vec![Email::builder()
.contexts(vec!["work".to_string()])
.email("joe.user@example.com")
.build()])
.urls(vec!["https://example.com/some-url".to_string()])
.build();
// WHEN
let actual = Contact::from_vcard(&contact.to_vcard()).expect("from vcard");
// THEN
assert_eq!(contact.full_name, actual.full_name);
assert_eq!(contact.name_parts, actual.name_parts);
assert_eq!(contact.kind, actual.kind);
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);
}
}

Some files were not shown because too many files have changed in this diff Show more