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