1macro_rules! with_report_methods {
29 ($mac:ident!($($prefix:tt)*)) => {
30 $mac! {
31 $($prefix)*
32 format_whois(response: crate::whois::WhoisResponse);
33 format_rdap(response: crate::rdap::RdapResponse);
34 format_dns(records: [crate::dns::DnsRecord]);
35 format_dig(result: crate::dns::DnsQueryResult);
36 format_dns_trace(trace: crate::dns::DnsTrace);
37 format_propagation(result: crate::dns::PropagationResult);
38 format_lookup(result: crate::lookup::LookupResult);
39 format_status(response: crate::status::StatusResponse);
40 format_follow_iteration(iteration: crate::dns::FollowIteration);
41 format_follow(result: crate::dns::FollowResult);
42 format_availability(result: crate::availability::AvailabilityResult);
43 format_dnssec(report: crate::dns::DnssecReport);
44 format_delegation(report: crate::dns::DelegationReport);
45 format_tld(info: crate::tld::TldInfo);
46 format_dns_comparison(comparison: crate::dns::DnsComparison);
47 format_subdomains(result: crate::subdomains::SubdomainResult);
48 format_diff(diff: crate::diff::DomainDiff);
49 format_ssl(report: crate::ssl::SslReport);
50 format_watch(report: crate::watchlist::WatchReport);
51 format_domain_info(info: crate::domain_info::DomainInfo);
52 format_drift(report: crate::drift::DriftReport);
53 format_posture(posture: crate::posture::EmailPosture);
54 format_headers(report: crate::headers::HeaderReport);
55 format_takeover(report: crate::takeover::TakeoverReport);
56 format_caa(policy: crate::caa::CaaPolicy);
57 format_confusables(report: crate::confusables::ConfusableReport);
58 format_subdomain_classification(result: crate::subdomains::SubdomainClassification);
59 format_subdomain_baseline_diff(report: crate::subdomains::SubdomainBaselineDiff);
60 }
61 };
62}
63
64macro_rules! declare_output_formatter {
65 ($($method:ident($arg:ident: $ty:ty);)+) => {
66 pub trait OutputFormatter {
69 $(fn $method(&self, $arg: &$ty) -> String;)+
70 }
71 };
72}
73
74macro_rules! impl_serializing {
77 ($formatter:ty, $render:ident; $($method:ident($arg:ident: $ty:ty);)+) => {
78 impl OutputFormatter for $formatter {
79 $(fn $method(&self, $arg: &$ty) -> String {
80 self.$render($arg)
81 })+
82 }
83 };
84}
85
86macro_rules! impl_forwarding {
93 ($formatter:ty; $($method:ident($arg:ident: $ty:ty);)+) => {
94 #[deny(unconditional_recursion)]
95 impl OutputFormatter for $formatter {
96 $(fn $method(&self, $arg: &$ty) -> String {
97 self.$method($arg)
98 })+
99 }
100 };
101}
102
103mod contact;
104pub mod dig;
105mod grouping;
106mod human;
107mod json;
108mod markdown;
109
110pub use dig::{dig_short, dig_trace_short};
111pub use human::{sanitize_line, HumanFormatter};
112pub use json::JsonFormatter;
113pub use markdown::MarkdownFormatter;
114
115use chrono::{DateTime, Utc};
116use serde::{Deserialize, Serialize};
117
118fn days_until(when: DateTime<Utc>) -> i64 {
122 crate::dates::days_until(when, Utc::now())
123}
124
125const DNSSEC_NOTE: &str = "Note: DNS responses are not DNSSEC-validated";
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
131#[serde(rename_all = "lowercase")]
132pub enum OutputFormat {
133 #[default]
134 Human,
135 Json,
136 Yaml,
137 Markdown,
138}
139
140impl std::str::FromStr for OutputFormat {
141 type Err = String;
142
143 fn from_str(s: &str) -> Result<Self, Self::Err> {
144 match s.to_lowercase().as_str() {
145 "human" | "text" | "pretty" => Ok(OutputFormat::Human),
146 "json" => Ok(OutputFormat::Json),
147 "yaml" | "yml" => Ok(OutputFormat::Yaml),
148 "markdown" | "md" => Ok(OutputFormat::Markdown),
149 _ => Err(format!(
150 "Unknown output format: {}. Use: human, json, yaml, markdown",
151 s
152 )),
153 }
154 }
155}
156
157with_report_methods!(declare_output_formatter!());
158
159#[derive(Default)]
161pub struct YamlFormatter;
162
163impl YamlFormatter {
164 pub fn new() -> Self {
165 Self
166 }
167
168 pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
170 match serde_json::to_value(value) {
172 Ok(v) => format_as_yaml(&v, 0),
173 Err(e) => format!("error: {}", e),
174 }
175 }
176}
177
178with_report_methods!(impl_serializing!(YamlFormatter, to_yaml_value;));
179
180fn yaml_needs_quoting(s: &str) -> bool {
199 if s.is_empty() || s != s.trim() {
200 return true;
201 }
202 if s.chars()
203 .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
204 {
205 return true;
206 }
207 if matches!(
208 s.to_ascii_lowercase().as_str(),
209 "null" | "~" | "true" | "false" | "yes" | "no" | "y" | "n" | "on" | "off" | "=" | "<<"
210 ) {
211 return true;
212 }
213 if let Some(first) = s.chars().next() {
214 if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
216 return true;
217 }
218 if first.is_ascii_digit() || first == '+' || first == '.' {
220 return true;
221 }
222 }
223 s.contains(": ") || s.ends_with(':') || s.contains(" #")
224}
225
226fn yaml_quote(s: &str) -> String {
230 let mut out = String::with_capacity(s.len() + 2);
231 out.push('"');
232 for c in s.chars() {
233 match c {
234 '"' => out.push_str("\\\""),
235 '\\' => out.push_str("\\\\"),
236 '\n' => out.push_str("\\n"),
237 '\t' => out.push_str("\\t"),
238 '\r' => out.push_str("\\r"),
239 '\0' => out.push_str("\\0"),
240 '\u{0085}' => out.push_str("\\N"),
243 '\u{2028}' => out.push_str("\\L"),
244 '\u{2029}' => out.push_str("\\P"),
245 c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
247 c => out.push(c),
248 }
249 }
250 out.push('"');
251 out
252}
253
254fn yaml_scalar(s: &str) -> String {
256 if yaml_needs_quoting(s) {
257 yaml_quote(s)
258 } else {
259 s.to_string()
260 }
261}
262
263fn format_as_yaml(value: &serde_json::Value, indent: usize) -> String {
265 let prefix = " ".repeat(indent);
266 match value {
267 serde_json::Value::Null => "null".to_string(),
268 serde_json::Value::Bool(b) => b.to_string(),
269 serde_json::Value::Number(n) => n.to_string(),
270 serde_json::Value::String(s) => yaml_scalar(s),
271 serde_json::Value::Array(arr) => {
272 if arr.is_empty() {
273 return "[]".to_string();
274 }
275 let mut out = String::new();
276 for item in arr {
277 out.push('\n');
278 out.push_str(&prefix);
279 out.push_str("- ");
280 let formatted = format_as_yaml(item, indent + 1);
281 out.push_str(&formatted);
282 }
283 out
284 }
285 serde_json::Value::Object(map) => {
286 if map.is_empty() {
287 return "{}".to_string();
288 }
289 let mut out = String::new();
290 let mut first = indent == 0;
291 for (key, val) in map {
292 if !first {
293 out.push('\n');
294 }
295 first = false;
296 out.push_str(&prefix);
297 out.push_str(&yaml_scalar(key));
300 out.push_str(": ");
301 match val {
302 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
303 out.push_str(&format_as_yaml(val, indent + 1));
304 }
305 _ => {
306 out.push_str(&format_as_yaml(val, indent));
307 }
308 }
309 }
310 out
311 }
312 }
313}
314
315pub fn get_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
316 match format {
317 OutputFormat::Human => Box::new(HumanFormatter::new()),
318 OutputFormat::Json => Box::new(JsonFormatter::new()),
319 OutputFormat::Yaml => Box::new(YamlFormatter::new()),
320 OutputFormat::Markdown => Box::new(MarkdownFormatter::new()),
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn test_output_format_from_str() {
330 assert_eq!(
331 "human".parse::<OutputFormat>().unwrap(),
332 OutputFormat::Human
333 );
334 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
335 assert_eq!("yaml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
336 assert_eq!("yml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
337 assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Human);
338 assert_eq!(
339 "pretty".parse::<OutputFormat>().unwrap(),
340 OutputFormat::Human
341 );
342 assert!("invalid".parse::<OutputFormat>().is_err());
343 }
344
345 #[test]
346 fn test_output_format_default() {
347 assert_eq!(OutputFormat::default(), OutputFormat::Human);
348 }
349
350 #[test]
351 fn test_get_formatter_returns_correct_type() {
352 let _ = get_formatter(OutputFormat::Human);
354 let _ = get_formatter(OutputFormat::Json);
355 let _ = get_formatter(OutputFormat::Yaml);
356 }
357
358 #[test]
359 fn test_yaml_formatter_basic() {
360 let formatter = YamlFormatter::new();
361 let status = crate::status::StatusResponse::new("example.com".to_string());
362 let output = formatter.format_status(&status);
363 assert!(output.contains("example.com"));
364 assert!(output.contains("domain"));
365 }
366
367 #[test]
368 fn test_format_as_yaml_primitives() {
369 assert_eq!(format_as_yaml(&serde_json::json!(null), 0), "null");
370 assert_eq!(format_as_yaml(&serde_json::json!(true), 0), "true");
371 assert_eq!(format_as_yaml(&serde_json::json!(42), 0), "42");
372 assert_eq!(format_as_yaml(&serde_json::json!("hello"), 0), "hello");
373 }
374
375 #[test]
376 fn test_format_as_yaml_array() {
377 let output = format_as_yaml(&serde_json::json!([1, 2, 3]), 0);
378 assert!(output.contains("- 1"));
379 assert!(output.contains("- 2"));
380 assert!(output.contains("- 3"));
381 }
382
383 #[test]
384 fn test_format_as_yaml_empty_collections() {
385 assert_eq!(format_as_yaml(&serde_json::json!([]), 0), "[]");
386 assert_eq!(format_as_yaml(&serde_json::json!({}), 0), "{}");
387 }
388
389 #[test]
392 fn yaml_scalar_quoting_hardened() {
393 use serde_json::json;
394 let y = |s: &str| format_as_yaml(&json!(s), 0);
395
396 assert_eq!(y("No"), "\"No\"");
398 assert_eq!(y("true"), "\"true\"");
399 assert_eq!(y("null"), "\"null\"");
400 assert_eq!(y("~"), "\"~\"");
401
402 assert_eq!(y(""), "\"\"");
404 assert_eq!(y(" leading"), "\" leading\"");
405 assert_eq!(y("trailing "), "\"trailing \"");
406
407 for s in [
409 "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&", "|pipe", ">gt",
410 "%pct", "`tick", "?q", ",comma",
411 ] {
412 assert_eq!(
413 y(s),
414 format!("\"{s}\""),
415 "must quote leading-indicator {s:?}"
416 );
417 }
418
419 assert_eq!(y("key: value"), "\"key: value\"");
421
422 assert_eq!(y("a\tb"), "\"a\\tb\"");
424 assert_eq!(y("a\rb"), "\"a\\rb\"");
425 assert_eq!(y("x\x1b[31m"), "\"x\\x1b[31m\"");
426
427 assert_eq!(y("plain-value"), "plain-value");
429 assert_eq!(y("has internal spaces"), "has internal spaces");
430 assert_eq!(y("ns1.example.com"), "ns1.example.com");
431 }
432
433 #[test]
434 fn yaml_quotes_strings_a_resolver_would_read_as_non_strings() {
435 use serde_json::json;
436 let y = |s: &str| format_as_yaml(&json!(s), 0);
437
438 for s in [
439 "+1.5555550100", "292", "0123", "1:20", "2024-01-15", "2024-01-15T04:00:00Z", ".inf",
446 ".NaN",
447 "=", "<<", "y", "N",
451 ] {
452 assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
453 }
454
455 assert_eq!(y("ns1.example.com"), "ns1.example.com");
457 assert_eq!(y("yes-but-longer"), "yes-but-longer");
458 assert_eq!(y("a=b"), "a=b");
459 assert_eq!(format_as_yaml(&json!(292), 0), "292");
460 assert_eq!(format_as_yaml(&json!(1.5), 0), "1.5");
461 }
462
463 #[test]
464 fn yaml_escapes_unicode_line_breaks() {
465 use serde_json::json;
466 let y = |s: &str| format_as_yaml(&json!(s), 0);
471 assert_eq!(y("a\u{2028}b"), "\"a\\Lb\"");
472 assert_eq!(y("a\u{2029}b"), "\"a\\Pb\"");
473 assert_eq!(y("a\u{0085}b"), "\"a\\Nb\"");
474
475 let doc = format_as_yaml(&json!({ "k\u{2028}ey": "v" }), 0);
476 assert_eq!(doc, "\"k\\Ley\": v");
477 }
478
479 #[test]
480 fn yaml_object_keys_with_significant_chars_are_quoted() {
481 let doc = format_as_yaml(&serde_json::json!({ "a\nb": "v" }), 0);
484 assert!(
485 doc.contains("\"a\\nb\""),
486 "malicious key must be quoted/escaped: {doc:?}"
487 );
488 let doc = format_as_yaml(&serde_json::json!({ "domain": "x" }), 0);
490 assert!(
491 doc.starts_with("domain:"),
492 "plain key must not be quoted: {doc:?}"
493 );
494 }
495}