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//! [`dig_short`] and [`dig_trace_short`] are the `dig +short` rendering of a
13//! query result and a trace: bare values, one per line, sanitized for a
14//! terminal — a plain-text mode outside the four formats. [`dig`] also holds
15//! the wording of a query's outcome (NXDOMAIN vs NODATA, the wildcard note),
16//! shared by the formatters and the TUI's DNS lens, and [`sanitize_line`] is
17//! the terminal-safety guard the human formatter applies to remote strings.
18
19// The report-method list and the impl generators below must precede the
20// `mod` declarations: `macro_rules!` is textually scoped, and the human and
21// markdown modules invoke `impl_forwarding!` where their inherent methods
22// are visible.
23
24/// Calls `$mac!` with every report type a formatter renders, one
25/// `method(arg: Type);` row each. This list is the single source of truth for
26/// [`OutputFormatter`] and all four of its impls: adding a report type is one
27/// row here plus the human and markdown inherent methods of the same name.
28macro_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        /// Renders every report type in one output format; pick an
67        /// implementation with [`get_formatter`].
68        pub trait OutputFormatter {
69            $(fn $method(&self, $arg: &$ty) -> String;)+
70        }
71    };
72}
73
74/// Implements [`OutputFormatter`] for a data-format formatter by passing every
75/// report to one serializing method (`to_json`, `to_yaml_value`).
76macro_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
86/// Implements [`OutputFormatter`] by forwarding each method to the inherent
87/// method of the same name, defined in the formatter's per-concern
88/// submodules. Rust resolves the inherent method first, so this does not
89/// recurse; it must be invoked where those inherent methods are visible.
90/// A missing inherent method would make the forwarder call itself, so
91/// `unconditional_recursion` is denied: a compile error, not a stack overflow.
92macro_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
118/// Whole days from now until `when` — see [`crate::dates::days_until`]: an
119/// expiry any time in the past is negative, so it never renders as
120/// "expires in 0 days!".
121fn days_until(when: DateTime<Utc>) -> i64 {
122    crate::dates::days_until(when, Utc::now())
123}
124
125/// The DNSSEC disclosure (M12) that closes every block of DNS answers:
126/// seer's resolver does not validate DNSSEC, and UDP DNS is trivially
127/// spoofable. The human formatters print it as is, Markdown as a quote.
128const 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/// YAML output formatter that converts data structures to YAML format.
160#[derive(Default)]
161pub struct YamlFormatter;
162
163impl YamlFormatter {
164    pub fn new() -> Self {
165        Self
166    }
167
168    /// Formats any serializable value as YAML output.
169    pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
170        // Convert to JSON value first, then format as YAML-like output
171        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
180/// Returns true when a string cannot be emitted as a YAML *plain* scalar and
181/// must be double-quoted. Covers the cases the old `contains('\n'|':'|'#')`
182/// predicate missed (issue #54): empty strings, leading/trailing whitespace,
183/// any control character or YAML line break (U+0085/U+2028/U+2029 — a raw one
184/// makes the whole document unparseable), a leading YAML indicator character,
185/// an interior `": "` / trailing `:` / `" #"` (which break a plain scalar),
186/// and the YAML 1.1 bool/null tokens (`null`, `~`, `true`, `false`, `yes`,
187/// `no`, `y`, `n`, `on`, `off`) and special keys (`=` value, `<<` merge)
188/// which would otherwise round-trip as the wrong type or fail to load.
189///
190/// Anything starting with an ASCII digit, `+`, or `.` is quoted too: that is
191/// how a YAML 1.1/1.2 resolver comes to read a plain scalar as a number or
192/// timestamp (`+1.5555550100` → float, `292` → int, `0123` → octal, `1:20` →
193/// sexagesimal, `2024-01-15` → date, `.inf`/`.nan` → float). Only JSON
194/// *strings* reach this function (numbers are emitted unquoted by
195/// `format_as_yaml`), so such a value must stay a string. The rule
196/// over-quotes some harmless values (e.g. IPv4 addresses), which is cheap and
197/// keeps it auditable.
198fn 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        // A leading YAML indicator char makes the whole scalar non-plain.
215        if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
216            return true;
217        }
218        // Possible number / timestamp / special float (see doc comment).
219        if first.is_ascii_digit() || first == '+' || first == '.' {
220            return true;
221        }
222    }
223    s.contains(": ") || s.ends_with(':') || s.contains(" #")
224}
225
226/// Emits `s` as a YAML double-quoted scalar, escaping `"`, `\`, control
227/// characters, and the YAML line-break characters (so attacker ANSI/control
228/// bytes can't reach the terminal or break the document).
229fn 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            // YAML line breaks: next line (a C1 control, so it must precede
241            // the generic arm below), line separator, paragraph separator.
242            '\u{0085}' => out.push_str("\\N"),
243            '\u{2028}' => out.push_str("\\L"),
244            '\u{2029}' => out.push_str("\\P"),
245            // C0/C1 controls + DEL → YAML \xXX escape (all <= 0x9F, two digits).
246            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
254/// Emits a scalar string as a plain YAML scalar when safe, else double-quoted.
255fn yaml_scalar(s: &str) -> String {
256    if yaml_needs_quoting(s) {
257        yaml_quote(s)
258    } else {
259        s.to_string()
260    }
261}
262
263/// Simple YAML-like formatter from serde_json::Value.
264fn 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                // Keys can be attacker-controlled (e.g. RDAP `extra` flattened
298                // map), so quote them under the same rules as scalar values.
299                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        // Just verify we can get formatters without panicking
353        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    // --- #54: YAML scalar quoting hardening ----------------------------
390
391    #[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        // YAML 1.1 bool/null tokens must be quoted to stay strings.
397        assert_eq!(y("No"), "\"No\"");
398        assert_eq!(y("true"), "\"true\"");
399        assert_eq!(y("null"), "\"null\"");
400        assert_eq!(y("~"), "\"~\"");
401
402        // Empty + leading/trailing whitespace.
403        assert_eq!(y(""), "\"\"");
404        assert_eq!(y(" leading"), "\" leading\"");
405        assert_eq!(y("trailing "), "\"trailing \"");
406
407        // Leading YAML indicator characters.
408        for s in [
409            "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&amp", "|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        // Colon-space breaks a plain scalar.
420        assert_eq!(y("key: value"), "\"key: value\"");
421
422        // Control chars / ANSI are escaped, never emitted raw.
423        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        // Safe plain scalars must NOT be over-quoted.
428        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",        // WHOIS phone → float 1.55555501
440            "292",                  // registrar IANA id string → int
441            "0123",                 // YAML 1.1 octal → 83
442            "1:20",                 // YAML 1.1 sexagesimal → 80
443            "2024-01-15",           // date
444            "2024-01-15T04:00:00Z", // RFC 3339 timestamp → datetime
445            ".inf",
446            ".NaN",
447            "=",  // YAML 1.1 value key: PyYAML raises ConstructorError
448            "<<", // merge key: PyYAML raises ConstructorError
449            "y",  // YAML 1.1 single-letter bools, any case
450            "N",
451        ] {
452            assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
453        }
454
455        // Hostnames and words stay plain; real JSON numbers are never quoted.
456        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        // U+2028/U+2029 aren't `char::is_control`, so they used to be emitted
467        // raw — and a raw one (or U+0085) makes the whole document fail to
468        // parse. They must be quoted and escaped with YAML's \L / \P / \N, in
469        // values and in keys alike.
470        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        // Keys from attacker-controlled flattened maps (e.g. RDAP `extra`) must
482        // be quoted too, or a key like "a\ninjected: true" forges structure.
483        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        // Normal identifier keys stay unquoted.
489        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}