macro_rules! with_report_methods {
($mac:ident!($($prefix:tt)*)) => {
$mac! {
$($prefix)*
format_whois(response: crate::whois::WhoisResponse);
format_rdap(response: crate::rdap::RdapResponse);
format_dns(records: [crate::dns::DnsRecord]);
format_dig(result: crate::dns::DnsQueryResult);
format_dns_trace(trace: crate::dns::DnsTrace);
format_propagation(result: crate::dns::PropagationResult);
format_lookup(result: crate::lookup::LookupResult);
format_status(response: crate::status::StatusResponse);
format_follow_iteration(iteration: crate::dns::FollowIteration);
format_follow(result: crate::dns::FollowResult);
format_availability(result: crate::availability::AvailabilityResult);
format_dnssec(report: crate::dns::DnssecReport);
format_delegation(report: crate::dns::DelegationReport);
format_tld(info: crate::tld::TldInfo);
format_dns_comparison(comparison: crate::dns::DnsComparison);
format_subdomains(result: crate::subdomains::SubdomainResult);
format_diff(diff: crate::diff::DomainDiff);
format_ssl(report: crate::ssl::SslReport);
format_watch(report: crate::watchlist::WatchReport);
format_domain_info(info: crate::domain_info::DomainInfo);
format_drift(report: crate::drift::DriftReport);
format_posture(posture: crate::posture::EmailPosture);
format_headers(report: crate::headers::HeaderReport);
format_audit(report: crate::audit::AuditReport);
format_takeover(report: crate::takeover::TakeoverReport);
format_caa(policy: crate::caa::CaaPolicy);
format_confusables(report: crate::confusables::ConfusableReport);
format_subdomain_classification(result: crate::subdomains::SubdomainClassification);
format_subdomain_baseline_diff(report: crate::subdomains::SubdomainBaselineDiff);
}
};
}
macro_rules! declare_output_formatter {
($($method:ident($arg:ident: $ty:ty);)+) => {
pub trait OutputFormatter {
$(fn $method(&self, $arg: &$ty) -> String;)+
}
};
}
macro_rules! impl_serializing {
($formatter:ty, $render:ident; $($method:ident($arg:ident: $ty:ty);)+) => {
impl OutputFormatter for $formatter {
$(fn $method(&self, $arg: &$ty) -> String {
self.$render($arg)
})+
}
};
}
macro_rules! impl_forwarding {
($formatter:ty; $($method:ident($arg:ident: $ty:ty);)+) => {
#[deny(unconditional_recursion)]
impl OutputFormatter for $formatter {
$(fn $method(&self, $arg: &$ty) -> String {
self.$method($arg)
})+
}
};
}
mod contact;
mod diff_table;
pub mod dig;
mod grouping;
mod human;
mod json;
mod markdown;
pub use dig::{dig_short, dig_trace_short};
pub use human::HumanFormatter;
pub use json::JsonFormatter;
pub use markdown::MarkdownFormatter;
use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use crate::dns::{AuthenticationTier, PropagationResult, PropagationVerdict};
use crate::lookup::LookupResult;
use crate::subdomains::SubdomainStatus;
use crate::takeover::TakeoverVerdict;
static_regex! {
ANSI_ESCAPE_RE = r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[A-Z@-_]";
}
fn is_invisible_format(c: char) -> bool {
matches!(
c,
'\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2066}'..='\u{2069}'
| '\u{061C}'
| '\u{FEFF}'
)
}
pub fn sanitize_line(s: &str) -> String {
ANSI_ESCAPE_RE
.replace_all(s, "")
.chars()
.filter_map(|c| match c {
'\n' | '\t' | '\u{2028}' | '\u{2029}' => Some(' '),
c if c.is_control() || is_invisible_format(c) => None,
c => Some(c),
})
.collect()
}
fn days_until(when: DateTime<Utc>) -> i64 {
crate::dates::days_until(when, Utc::now())
}
fn day_count(days: i64) -> String {
if days == 1 {
"1 day".to_string()
} else {
format!("{days} days")
}
}
fn expiry_phrase(days_until: i64) -> String {
if days_until < 0 {
format!("expired {} ago", day_count(-days_until))
} else {
format!("expires in {}", day_count(days_until))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Emphasis {
Good,
Neutral,
Caution,
Bad,
}
fn expiry_emphasis(days_until: i64) -> Emphasis {
match days_until {
d if d < 30 => Emphasis::Bad,
d if d < 90 => Emphasis::Caution,
_ => Emphasis::Good,
}
}
pub fn availability_label(verdict: &str) -> (&'static str, Emphasis) {
match verdict {
"available" => ("AVAILABLE", Emphasis::Good),
"likely_available" => ("MAY BE AVAILABLE", Emphasis::Caution),
"registered" => ("REGISTERED", Emphasis::Neutral),
"likely_registered" => ("LIKELY REGISTERED", Emphasis::Caution),
_ => ("UNKNOWN", Emphasis::Bad),
}
}
fn lookup_source(result: &LookupResult) -> &'static str {
match result {
LookupResult::Rdap { .. } => "RDAP",
LookupResult::Whois {
rdap_error: None, ..
} => "WHOIS",
LookupResult::Whois { .. }
| LookupResult::Available {
whois_data: Some(_),
..
} => "WHOIS (RDAP unavailable)",
LookupResult::Available { .. } => "availability check (RDAP and WHOIS failed)",
}
}
pub(crate) fn dns_delegation_verdict(ns: &crate::DnsNameservers) -> String {
if ns.in_sync {
return "matches WHOIS".to_string();
}
if ns.whois_lists_none() {
return "WHOIS lists no nameservers".to_string();
}
let mut parts = Vec::new();
if !ns.missing_from_whois.is_empty() {
parts.push(format!(
"delegated but not in WHOIS: {}",
ns.missing_from_whois.join(", ")
));
}
if !ns.missing_from_dns.is_empty() {
parts.push(format!(
"in WHOIS but not delegated: {}",
ns.missing_from_dns.join(", ")
));
}
format!("differs from WHOIS ({})", parts.join("; "))
}
pub(crate) fn dns_delegation_differs(ns: &crate::DnsNameservers) -> bool {
!ns.in_sync && !ns.whois_lists_none()
}
fn format_duration(duration: TimeDelta) -> String {
let total_secs = duration.num_seconds();
if total_secs < 60 {
format!("{total_secs}s")
} else if total_secs < 3600 {
format!("{}m {}s", total_secs / 60, total_secs % 60)
} else {
format!("{}h {}m", total_secs / 3600, (total_secs % 3600) / 60)
}
}
const DNSSEC_NOTE: &str = "Note: DNS responses are not DNSSEC-validated";
fn dnssec_depth(tier: AuthenticationTier) -> (&'static str, Option<&'static str>) {
match tier {
AuthenticationTier::Unsigned => ("unsigned (no DNSSEC records)", None),
AuthenticationTier::DigestOnly => (
"digest-only (DS↔DNSKEY consistency)",
Some(
"Note: reflects DS/DNSKEY digest consistency only — RRSIG signatures, \
validity periods, and the chain to the root are NOT cryptographically \
verified.",
),
),
}
}
fn propagation_detail(result: &PropagationResult) -> String {
let responding = result.servers_responding;
match result.verdict() {
PropagationVerdict::NoAnswer => {
format!("none of the {} servers answered", result.servers_checked)
}
PropagationVerdict::Full if responding == 1 => {
"the one responding server answered".to_string()
}
PropagationVerdict::Full => format!("all {responding} responding servers agree"),
_ => format!(
"{} of {responding} responding servers agree",
result.servers_agreeing()
),
}
}
pub fn takeover_label(verdict: TakeoverVerdict) -> &'static str {
match verdict {
TakeoverVerdict::Vulnerable => "VULNERABLE",
TakeoverVerdict::Potential => "potential",
TakeoverVerdict::Inconclusive => "inconclusive",
TakeoverVerdict::Safe => "safe",
}
}
fn subdomain_status_label(status: SubdomainStatus) -> &'static str {
match status {
SubdomainStatus::Live => "live",
SubdomainStatus::Dead => "dead",
SubdomainStatus::Wildcard => "wildcard",
SubdomainStatus::Unknown => "unknown",
}
}
pub fn propagation_difference(inc: &crate::dns::Inconsistency, empty_label: &str) -> String {
const INLINE_VALUES: usize = 6;
if inc.values.is_empty() {
return format!("no records ({empty_label})");
}
if inc.values.len() + inc.consensus.len() <= INLINE_VALUES {
return inc.values.join(", ");
}
let mut parts = Vec::new();
let missing = inc.missing_count();
if missing > 0 {
parts.push(format!("missing {missing} of {}", inc.consensus.len()));
}
let extra = inc.extra_values();
if !extra.is_empty() {
parts.push(format!("extra: {}", extra.join(", ")));
}
parts.join("; ")
}
const GEO_NOTE: &str = "Note: answers vary by resolver location, typical of GeoDNS/CDN \
names rather than a propagation delay";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
#[default]
Human,
Json,
Yaml,
Markdown,
}
impl std::str::FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"human" | "text" | "pretty" => Ok(OutputFormat::Human),
"json" => Ok(OutputFormat::Json),
"yaml" | "yml" => Ok(OutputFormat::Yaml),
"markdown" | "md" => Ok(OutputFormat::Markdown),
_ => Err(format!(
"Unknown output format: {}. Use: human, json, yaml, markdown",
s
)),
}
}
}
with_report_methods!(declare_output_formatter!());
#[derive(Default)]
pub struct YamlFormatter;
impl YamlFormatter {
pub fn new() -> Self {
Self
}
pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
match serde_json::to_value(value) {
Ok(v) => format_as_yaml(&v, 0),
Err(e) => format!("error: {}", yaml_scalar(&e.to_string())),
}
}
}
with_report_methods!(impl_serializing!(YamlFormatter, to_yaml_value;));
fn yaml_needs_quoting(s: &str) -> bool {
if s.is_empty() || s != s.trim() {
return true;
}
if s.chars()
.any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
{
return true;
}
if matches!(
s.to_ascii_lowercase().as_str(),
"null" | "~" | "true" | "false" | "yes" | "no" | "y" | "n" | "on" | "off" | "=" | "<<"
) {
return true;
}
if let Some(first) = s.chars().next() {
if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
return true;
}
if first.is_ascii_digit() || first == '+' || first == '.' {
return true;
}
}
s.contains(": ") || s.ends_with(':') || s.contains(" #")
}
fn yaml_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
'\0' => out.push_str("\\0"),
'\u{0085}' => out.push_str("\\N"),
'\u{2028}' => out.push_str("\\L"),
'\u{2029}' => out.push_str("\\P"),
c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn yaml_scalar(s: &str) -> String {
if yaml_needs_quoting(s) {
yaml_quote(s)
} else {
s.to_string()
}
}
fn is_yaml_block(value: &serde_json::Value) -> bool {
match value {
serde_json::Value::Array(arr) => !arr.is_empty(),
serde_json::Value::Object(map) => !map.is_empty(),
_ => false,
}
}
fn format_as_yaml(value: &serde_json::Value, indent: usize) -> String {
let prefix = " ".repeat(indent);
match value {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => yaml_scalar(s),
serde_json::Value::Array(arr) if arr.is_empty() => "[]".to_string(),
serde_json::Value::Object(map) if map.is_empty() => "{}".to_string(),
serde_json::Value::Array(arr) => {
let item_prefix = " ".repeat(indent + 1);
arr.iter()
.map(|item| {
let rendered = format_as_yaml(item, indent + 1);
let first_line = rendered.strip_prefix(&item_prefix).unwrap_or(&rendered);
format!("{prefix}- {first_line}")
})
.collect::<Vec<_>>()
.join("\n")
}
serde_json::Value::Object(map) => map
.iter()
.map(|(key, val)| {
let key = yaml_scalar(key);
if is_yaml_block(val) {
format!("{prefix}{key}:\n{}", format_as_yaml(val, indent + 1))
} else {
format!("{prefix}{key}: {}", format_as_yaml(val, indent))
}
})
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn get_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
match format {
OutputFormat::Human => Box::new(HumanFormatter::new()),
OutputFormat::Json => Box::new(JsonFormatter::new()),
OutputFormat::Yaml => Box::new(YamlFormatter::new()),
OutputFormat::Markdown => Box::new(MarkdownFormatter::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dns_delegation_with_no_whois_nameservers_is_not_a_mismatch() {
let ns = |w: &[&str], d: &[&str]| {
crate::DnsNameservers::compare(
&w.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
d.iter().map(|s| s.to_string()).collect(),
)
};
let none = ns(&[], &["a.ns.test", "b.ns.test"]);
assert!(none.whois_lists_none());
assert!(!dns_delegation_differs(&none));
assert_eq!(dns_delegation_verdict(&none), "WHOIS lists no nameservers");
let partial = ns(&["a.ns.test"], &["a.ns.test", "b.ns.test"]);
assert!(!partial.whois_lists_none());
assert!(dns_delegation_differs(&partial));
assert!(dns_delegation_verdict(&partial).starts_with("differs from WHOIS"));
let wrong = ns(&["x.ns.test"], &["a.ns.test"]);
assert!(!wrong.whois_lists_none());
assert!(dns_delegation_differs(&wrong));
let empty = ns(&[], &[]);
assert!(!empty.whois_lists_none());
assert!(!dns_delegation_differs(&empty));
}
#[test]
fn test_output_format_from_str() {
assert_eq!(
"human".parse::<OutputFormat>().unwrap(),
OutputFormat::Human
);
assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
assert_eq!("yaml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
assert_eq!("yml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Human);
assert_eq!(
"pretty".parse::<OutputFormat>().unwrap(),
OutputFormat::Human
);
assert!("invalid".parse::<OutputFormat>().is_err());
}
#[test]
fn test_output_format_default() {
assert_eq!(OutputFormat::default(), OutputFormat::Human);
}
#[test]
fn test_get_formatter_returns_correct_type() {
let _ = get_formatter(OutputFormat::Human);
let _ = get_formatter(OutputFormat::Json);
let _ = get_formatter(OutputFormat::Yaml);
}
#[test]
fn test_yaml_formatter_basic() {
let formatter = YamlFormatter::new();
let status = crate::status::StatusResponse::new("example.com".to_string());
let output = formatter.format_status(&status);
assert!(output.contains("example.com"));
assert!(output.contains("domain"));
}
#[test]
fn test_format_as_yaml_primitives() {
assert_eq!(format_as_yaml(&serde_json::json!(null), 0), "null");
assert_eq!(format_as_yaml(&serde_json::json!(true), 0), "true");
assert_eq!(format_as_yaml(&serde_json::json!(42), 0), "42");
assert_eq!(format_as_yaml(&serde_json::json!("hello"), 0), "hello");
}
#[test]
fn test_format_as_yaml_array() {
let output = format_as_yaml(&serde_json::json!([1, 2, 3]), 0);
assert!(output.contains("- 1"));
assert!(output.contains("- 2"));
assert!(output.contains("- 3"));
}
#[test]
fn yaml_is_clean_block_style_without_blank_or_trailing_space_lines() {
use serde_json::json;
let doc = format_as_yaml(
&json!([
{"name": "a", "ttl": 300},
{"name": "b", "tags": ["x", "z"], "empty": [], "meta": {"k": "v"}},
[1, 2]
]),
0,
);
let expected = "\
- name: a
ttl: 300
- empty: []
meta:
k: v
name: b
tags:
- x
- z
- - 1
- 2";
assert_eq!(doc, expected);
for line in doc.lines() {
assert!(!line.ends_with(' '), "trailing space: {line:?}");
}
let doc = format_as_yaml(&json!({"domain": "x", "records": [{"a": 1}]}), 0);
assert_eq!(doc, "domain: x\nrecords:\n - a: 1");
}
#[test]
fn sanitize_line_strips_controls_and_folds_line_breaks() {
assert_eq!(sanitize_line("a\rb\x08c\x07d\u{009b}e"), "abcde");
assert_eq!(
sanitize_line("line1\nline2\tcol\u{2028}x"),
"line1 line2 col x"
);
assert_eq!(
sanitize_line("\x1b]0;malicious title\x1b\\visible"),
"visible"
);
assert_eq!(sanitize_line("\x1b[31mred\x1b[0m"), "red");
}
#[test]
fn sanitize_line_drops_bidi_and_zero_width_format_chars() {
let trojan = "a\u{202E}b\u{2066}c\u{200B}d\u{FEFF}e\u{061C}f\u{200E}g\u{2069}";
assert_eq!(sanitize_line(trojan), "abcdefg");
assert_eq!(sanitize_line("café — ü"), "café — ü");
}
#[test]
fn expiry_phrase_is_one_wording_for_past_and_future() {
assert_eq!(expiry_phrase(-5), "expired 5 days ago");
assert_eq!(expiry_phrase(-1), "expired 1 day ago");
assert_eq!(expiry_phrase(0), "expires in 0 days");
assert_eq!(expiry_phrase(1), "expires in 1 day");
assert_eq!(expiry_phrase(29), "expires in 29 days");
assert_eq!(expiry_emphasis(-1), Emphasis::Bad);
assert_eq!(expiry_emphasis(29), Emphasis::Bad);
assert_eq!(expiry_emphasis(30), Emphasis::Caution);
assert_eq!(expiry_emphasis(90), Emphasis::Good);
}
#[test]
fn availability_label_covers_every_verdict_and_tolerates_others() {
assert_eq!(availability_label("available").0, "AVAILABLE");
assert_eq!(availability_label("likely_available").0, "MAY BE AVAILABLE");
assert_eq!(availability_label("registered").0, "REGISTERED");
assert_eq!(
availability_label("likely_registered").0,
"LIKELY REGISTERED"
);
assert_eq!(availability_label("unknown"), ("UNKNOWN", Emphasis::Bad));
assert_eq!(availability_label("whatever"), ("UNKNOWN", Emphasis::Bad));
}
#[test]
fn propagation_detail_names_a_lone_responder() {
use crate::dns::{
ConsensusValue, DnsRecord, DnsServer, DnsStatus, PropagationServerResult, RecordData,
RecordType,
};
let answered = PropagationServerResult {
server: DnsServer::new("Google", "8.8.8.8", "NA", "Google"),
records: vec![DnsRecord {
name: "example.com".into(),
record_type: RecordType::A,
ttl: 300,
data: RecordData::A {
address: "1.2.3.4".into(),
},
}],
response_time_ms: 12,
success: true,
error: None,
status: Some(DnsStatus::NoError),
};
let result = PropagationResult {
domain: "example.com".into(),
record_type: RecordType::A,
servers_checked: 2,
servers_responding: 1,
propagation_percentage: 100.0,
results: vec![answered],
consensus_values: vec![ConsensusValue::new(RecordType::A, "1.2.3.4")],
inconsistencies: vec![],
unreachable_servers: vec![],
dnssec_validated: false,
nameserver_details: None,
};
assert_eq!(
propagation_detail(&result),
"the one responding server answered"
);
let md = MarkdownFormatter::new().format_propagation(&result);
assert!(md.contains("the one responding server answered"), "{md}");
}
#[test]
fn dnssec_depth_notes_what_each_tier_does_not_verify() {
assert_eq!(dnssec_depth(AuthenticationTier::Unsigned).1, None);
let digest = dnssec_depth(AuthenticationTier::DigestOnly).1.unwrap();
assert!(digest.contains("RRSIG signatures, validity periods"));
}
#[test]
fn test_format_as_yaml_empty_collections() {
assert_eq!(format_as_yaml(&serde_json::json!([]), 0), "[]");
assert_eq!(format_as_yaml(&serde_json::json!({}), 0), "{}");
}
#[test]
fn yaml_scalar_quoting_hardened() {
use serde_json::json;
let y = |s: &str| format_as_yaml(&json!(s), 0);
assert_eq!(y("No"), "\"No\"");
assert_eq!(y("true"), "\"true\"");
assert_eq!(y("null"), "\"null\"");
assert_eq!(y("~"), "\"~\"");
assert_eq!(y(""), "\"\"");
assert_eq!(y(" leading"), "\" leading\"");
assert_eq!(y("trailing "), "\"trailing \"");
for s in [
"- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&", "|pipe", ">gt",
"%pct", "`tick", "?q", ",comma",
] {
assert_eq!(
y(s),
format!("\"{s}\""),
"must quote leading-indicator {s:?}"
);
}
assert_eq!(y("key: value"), "\"key: value\"");
assert_eq!(y("a\tb"), "\"a\\tb\"");
assert_eq!(y("a\rb"), "\"a\\rb\"");
assert_eq!(y("x\x1b[31m"), "\"x\\x1b[31m\"");
assert_eq!(y("plain-value"), "plain-value");
assert_eq!(y("has internal spaces"), "has internal spaces");
assert_eq!(y("ns1.example.com"), "ns1.example.com");
}
#[test]
fn yaml_quotes_strings_a_resolver_would_read_as_non_strings() {
use serde_json::json;
let y = |s: &str| format_as_yaml(&json!(s), 0);
for s in [
"+1.5555550100", "292", "0123", "1:20", "2024-01-15", "2024-01-15T04:00:00Z", ".inf",
".NaN",
"=", "<<", "y", "N",
] {
assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
}
assert_eq!(y("ns1.example.com"), "ns1.example.com");
assert_eq!(y("yes-but-longer"), "yes-but-longer");
assert_eq!(y("a=b"), "a=b");
assert_eq!(format_as_yaml(&json!(292), 0), "292");
assert_eq!(format_as_yaml(&json!(1.5), 0), "1.5");
}
#[test]
fn yaml_escapes_unicode_line_breaks() {
use serde_json::json;
let y = |s: &str| format_as_yaml(&json!(s), 0);
assert_eq!(y("a\u{2028}b"), "\"a\\Lb\"");
assert_eq!(y("a\u{2029}b"), "\"a\\Pb\"");
assert_eq!(y("a\u{0085}b"), "\"a\\Nb\"");
let doc = format_as_yaml(&json!({ "k\u{2028}ey": "v" }), 0);
assert_eq!(doc, "\"k\\Ley\": v");
}
#[test]
fn yaml_object_keys_with_significant_chars_are_quoted() {
let doc = format_as_yaml(&serde_json::json!({ "a\nb": "v" }), 0);
assert!(
doc.contains("\"a\\nb\""),
"malicious key must be quoted/escaped: {doc:?}"
);
let doc = format_as_yaml(&serde_json::json!({ "domain": "x" }), 0);
assert!(
doc.starts_with("domain:"),
"plain key must not be quoted: {doc:?}"
);
}
}