Skip to main content

seer_core/output/
mod.rs

1//! Output formatting for every seer-core result type.
2//!
3//! [`OutputFormatter`] has one `format_*` method per result type, implemented
4//! by the human (colored terminal), JSON, YAML and Markdown formatters. The
5//! method list is written once, in `with_report_methods!`, which generates the
6//! trait and all four impls.
7//! Callers pick one through [`get_formatter`] from an [`OutputFormat`], so the
8//! CLI, REPL, TUI raw view and clipboard copy all render identically. Human
9//! and Markdown output are pinned by `insta` snapshots in
10//! `seer-core/tests/format_snapshots.rs`.
11
12// The report-method list and the impl generators below must precede the
13// `mod` declarations: `macro_rules!` is textually scoped, and the human and
14// markdown modules invoke `impl_forwarding!` where their inherent methods
15// are visible.
16
17/// Calls `$mac!` with every report type a formatter renders, one
18/// `method(arg: Type);` row each. This list is the single source of truth for
19/// [`OutputFormatter`] and all four of its impls: adding a report type is one
20/// row here plus the human and markdown inherent methods of the same name.
21macro_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        /// Renders every report type in one output format; pick an
58        /// implementation with [`get_formatter`].
59        pub trait OutputFormatter {
60            $(fn $method(&self, $arg: &$ty) -> String;)+
61        }
62    };
63}
64
65/// Implements [`OutputFormatter`] for a data-format formatter by passing every
66/// report to one serializing method (`to_json`, `to_yaml_value`).
67macro_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
77/// Implements [`OutputFormatter`] by forwarding each method to the inherent
78/// method of the same name, defined in the formatter's per-concern
79/// submodules. Rust resolves the inherent method first, so this does not
80/// recurse; it must be invoked where those inherent methods are visible.
81/// A missing inherent method would make the forwarder call itself, so
82/// `unconditional_recursion` is denied: a compile error, not a stack overflow.
83macro_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
107/// Whole days from now until `when` — see [`crate::dates::days_until`]: an
108/// expiry any time in the past is negative, so it never renders as
109/// "expires in 0 days!".
110fn 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/// YAML output formatter that converts data structures to YAML format.
144#[derive(Default)]
145pub struct YamlFormatter;
146
147impl YamlFormatter {
148    pub fn new() -> Self {
149        Self
150    }
151
152    /// Formats any serializable value as YAML output.
153    pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
154        // Convert to JSON value first, then format as YAML-like output
155        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
164/// Returns true when a string cannot be emitted as a YAML *plain* scalar and
165/// must be double-quoted. Covers the cases the old `contains('\n'|':'|'#')`
166/// predicate missed (issue #54): empty strings, leading/trailing whitespace,
167/// any control character or YAML line break (U+0085/U+2028/U+2029 — a raw one
168/// makes the whole document unparseable), a leading YAML indicator character,
169/// an interior `": "` / trailing `:` / `" #"` (which break a plain scalar),
170/// and the YAML 1.1 bool/null tokens (`null`, `~`, `true`, `false`, `yes`,
171/// `no`, `y`, `n`, `on`, `off`) and special keys (`=` value, `<<` merge)
172/// which would otherwise round-trip as the wrong type or fail to load.
173///
174/// Anything starting with an ASCII digit, `+`, or `.` is quoted too: that is
175/// how a YAML 1.1/1.2 resolver comes to read a plain scalar as a number or
176/// timestamp (`+1.5555550100` → float, `292` → int, `0123` → octal, `1:20` →
177/// sexagesimal, `2024-01-15` → date, `.inf`/`.nan` → float). Only JSON
178/// *strings* reach this function (numbers are emitted unquoted by
179/// `format_as_yaml`), so such a value must stay a string. The rule
180/// over-quotes some harmless values (e.g. IPv4 addresses), which is cheap and
181/// keeps it auditable.
182fn 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        // A leading YAML indicator char makes the whole scalar non-plain.
199        if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
200            return true;
201        }
202        // Possible number / timestamp / special float (see doc comment).
203        if first.is_ascii_digit() || first == '+' || first == '.' {
204            return true;
205        }
206    }
207    s.contains(": ") || s.ends_with(':') || s.contains(" #")
208}
209
210/// Emits `s` as a YAML double-quoted scalar, escaping `"`, `\`, control
211/// characters, and the YAML line-break characters (so attacker ANSI/control
212/// bytes can't reach the terminal or break the document).
213fn 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            // YAML line breaks: next line (a C1 control, so it must precede
225            // the generic arm below), line separator, paragraph separator.
226            '\u{0085}' => out.push_str("\\N"),
227            '\u{2028}' => out.push_str("\\L"),
228            '\u{2029}' => out.push_str("\\P"),
229            // C0/C1 controls + DEL → YAML \xXX escape (all <= 0x9F, two digits).
230            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
238/// Emits a scalar string as a plain YAML scalar when safe, else double-quoted.
239fn yaml_scalar(s: &str) -> String {
240    if yaml_needs_quoting(s) {
241        yaml_quote(s)
242    } else {
243        s.to_string()
244    }
245}
246
247/// Simple YAML-like formatter from serde_json::Value.
248fn 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                // Keys can be attacker-controlled (e.g. RDAP `extra` flattened
282                // map), so quote them under the same rules as scalar values.
283                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        // Just verify we can get formatters without panicking
337        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    // --- #54: YAML scalar quoting hardening ----------------------------
374
375    #[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        // YAML 1.1 bool/null tokens must be quoted to stay strings.
381        assert_eq!(y("No"), "\"No\"");
382        assert_eq!(y("true"), "\"true\"");
383        assert_eq!(y("null"), "\"null\"");
384        assert_eq!(y("~"), "\"~\"");
385
386        // Empty + leading/trailing whitespace.
387        assert_eq!(y(""), "\"\"");
388        assert_eq!(y(" leading"), "\" leading\"");
389        assert_eq!(y("trailing "), "\"trailing \"");
390
391        // Leading YAML indicator characters.
392        for s in [
393            "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&amp", "|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        // Colon-space breaks a plain scalar.
404        assert_eq!(y("key: value"), "\"key: value\"");
405
406        // Control chars / ANSI are escaped, never emitted raw.
407        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        // Safe plain scalars must NOT be over-quoted.
412        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",        // WHOIS phone → float 1.55555501
424            "292",                  // registrar IANA id string → int
425            "0123",                 // YAML 1.1 octal → 83
426            "1:20",                 // YAML 1.1 sexagesimal → 80
427            "2024-01-15",           // date
428            "2024-01-15T04:00:00Z", // RFC 3339 timestamp → datetime
429            ".inf",
430            ".NaN",
431            "=",  // YAML 1.1 value key: PyYAML raises ConstructorError
432            "<<", // merge key: PyYAML raises ConstructorError
433            "y",  // YAML 1.1 single-letter bools, any case
434            "N",
435        ] {
436            assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
437        }
438
439        // Hostnames and words stay plain; real JSON numbers are never quoted.
440        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        // U+2028/U+2029 aren't `char::is_control`, so they used to be emitted
451        // raw — and a raw one (or U+0085) makes the whole document fail to
452        // parse. They must be quoted and escaped with YAML's \L / \P / \N, in
453        // values and in keys alike.
454        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        // Keys from attacker-controlled flattened maps (e.g. RDAP `extra`) must
466        // be quoted too, or a key like "a\ninjected: true" forges structure.
467        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        // Normal identifier keys stay unquoted.
473        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}