Skip to main content

seer_core/output/
mod.rs

1mod grouping;
2mod human;
3mod json;
4mod markdown;
5
6pub use human::HumanFormatter;
7pub use json::JsonFormatter;
8pub use markdown::MarkdownFormatter;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13/// Whole days from now until `when` — see [`crate::dates::days_until`]: an
14/// expiry any time in the past is negative, so it never renders as
15/// "expires in 0 days!".
16fn days_until(when: DateTime<Utc>) -> i64 {
17    crate::dates::days_until(when, Utc::now())
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum OutputFormat {
23    #[default]
24    Human,
25    Json,
26    Yaml,
27    Markdown,
28}
29
30impl std::str::FromStr for OutputFormat {
31    type Err = String;
32
33    fn from_str(s: &str) -> Result<Self, Self::Err> {
34        match s.to_lowercase().as_str() {
35            "human" | "text" | "pretty" => Ok(OutputFormat::Human),
36            "json" => Ok(OutputFormat::Json),
37            "yaml" | "yml" => Ok(OutputFormat::Yaml),
38            "markdown" | "md" => Ok(OutputFormat::Markdown),
39            _ => Err(format!(
40                "Unknown output format: {}. Use: human, json, yaml, markdown",
41                s
42            )),
43        }
44    }
45}
46
47pub trait OutputFormatter {
48    fn format_whois(&self, response: &crate::whois::WhoisResponse) -> String;
49    fn format_rdap(&self, response: &crate::rdap::RdapResponse) -> String;
50    fn format_dns(&self, records: &[crate::dns::DnsRecord]) -> String;
51    fn format_propagation(&self, result: &crate::dns::PropagationResult) -> String;
52    fn format_lookup(&self, result: &crate::lookup::LookupResult) -> String;
53    fn format_status(&self, response: &crate::status::StatusResponse) -> String;
54    fn format_follow_iteration(&self, iteration: &crate::dns::FollowIteration) -> String;
55    fn format_follow(&self, result: &crate::dns::FollowResult) -> String;
56    fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String;
57    fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String;
58    fn format_delegation(&self, report: &crate::dns::DelegationReport) -> String;
59    fn format_tld(&self, info: &crate::tld::TldInfo) -> String;
60    fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String;
61    fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String;
62    fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String;
63    fn format_ssl(&self, report: &crate::ssl::SslReport) -> String;
64    fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String;
65    fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String;
66    fn format_drift(&self, report: &crate::drift::DriftReport) -> String;
67    fn format_posture(&self, posture: &crate::posture::EmailPosture) -> String;
68    fn format_headers(&self, report: &crate::headers::HeaderReport) -> String;
69    fn format_takeover(&self, report: &crate::takeover::TakeoverReport) -> String;
70    fn format_caa(&self, policy: &crate::caa::CaaPolicy) -> String;
71    fn format_confusables(&self, report: &crate::confusables::ConfusableReport) -> String;
72    fn format_subdomain_classification(
73        &self,
74        result: &crate::subdomains::SubdomainClassification,
75    ) -> String;
76    fn format_subdomain_baseline_diff(
77        &self,
78        report: &crate::subdomains::SubdomainBaselineDiff,
79    ) -> String;
80}
81
82/// YAML output formatter that converts data structures to YAML format.
83pub struct YamlFormatter;
84
85impl YamlFormatter {
86    pub fn new() -> Self {
87        Self
88    }
89
90    /// Formats any serializable value as YAML output.
91    pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
92        // Convert to JSON value first, then format as YAML-like output
93        match serde_json::to_value(value) {
94            Ok(v) => format_as_yaml(&v, 0),
95            Err(e) => format!("error: {}", e),
96        }
97    }
98}
99
100impl Default for YamlFormatter {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl OutputFormatter for YamlFormatter {
107    fn format_whois(&self, response: &crate::whois::WhoisResponse) -> String {
108        self.to_yaml_value(response)
109    }
110    fn format_rdap(&self, response: &crate::rdap::RdapResponse) -> String {
111        self.to_yaml_value(response)
112    }
113    fn format_dns(&self, records: &[crate::dns::DnsRecord]) -> String {
114        self.to_yaml_value(records)
115    }
116    fn format_propagation(&self, result: &crate::dns::PropagationResult) -> String {
117        self.to_yaml_value(result)
118    }
119    fn format_lookup(&self, result: &crate::lookup::LookupResult) -> String {
120        self.to_yaml_value(result)
121    }
122    fn format_status(&self, response: &crate::status::StatusResponse) -> String {
123        self.to_yaml_value(response)
124    }
125    fn format_follow_iteration(&self, iteration: &crate::dns::FollowIteration) -> String {
126        self.to_yaml_value(iteration)
127    }
128    fn format_follow(&self, result: &crate::dns::FollowResult) -> String {
129        self.to_yaml_value(result)
130    }
131    fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
132        self.to_yaml_value(result)
133    }
134    fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
135        self.to_yaml_value(report)
136    }
137    fn format_delegation(&self, report: &crate::dns::DelegationReport) -> String {
138        self.to_yaml_value(report)
139    }
140    fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
141        self.to_yaml_value(info)
142    }
143    fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
144        self.to_yaml_value(comparison)
145    }
146    fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
147        self.to_yaml_value(result)
148    }
149    fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
150        self.to_yaml_value(diff)
151    }
152    fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
153        self.to_yaml_value(report)
154    }
155    fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
156        self.to_yaml_value(report)
157    }
158    fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
159        self.to_yaml_value(info)
160    }
161    fn format_drift(&self, report: &crate::drift::DriftReport) -> String {
162        self.to_yaml_value(report)
163    }
164    fn format_posture(&self, posture: &crate::posture::EmailPosture) -> String {
165        self.to_yaml_value(posture)
166    }
167    fn format_headers(&self, report: &crate::headers::HeaderReport) -> String {
168        self.to_yaml_value(report)
169    }
170    fn format_takeover(&self, report: &crate::takeover::TakeoverReport) -> String {
171        self.to_yaml_value(report)
172    }
173    fn format_caa(&self, policy: &crate::caa::CaaPolicy) -> String {
174        self.to_yaml_value(policy)
175    }
176    fn format_confusables(&self, report: &crate::confusables::ConfusableReport) -> String {
177        self.to_yaml_value(report)
178    }
179    fn format_subdomain_classification(
180        &self,
181        result: &crate::subdomains::SubdomainClassification,
182    ) -> String {
183        self.to_yaml_value(result)
184    }
185    fn format_subdomain_baseline_diff(
186        &self,
187        report: &crate::subdomains::SubdomainBaselineDiff,
188    ) -> String {
189        self.to_yaml_value(report)
190    }
191}
192
193/// Returns true when a string cannot be emitted as a YAML *plain* scalar and
194/// must be double-quoted. Covers the cases the old `contains('\n'|':'|'#')`
195/// predicate missed (issue #54): empty strings, leading/trailing whitespace,
196/// any control character or YAML line break (U+0085/U+2028/U+2029 — a raw one
197/// makes the whole document unparseable), a leading YAML indicator character,
198/// an interior `": "` / trailing `:` / `" #"` (which break a plain scalar),
199/// and the YAML 1.1 bool/null tokens (`null`, `~`, `true`, `false`, `yes`,
200/// `no`, `y`, `n`, `on`, `off`) and special keys (`=` value, `<<` merge)
201/// which would otherwise round-trip as the wrong type or fail to load.
202///
203/// Anything starting with an ASCII digit, `+`, or `.` is quoted too: that is
204/// how a YAML 1.1/1.2 resolver comes to read a plain scalar as a number or
205/// timestamp (`+1.5555550100` → float, `292` → int, `0123` → octal, `1:20` →
206/// sexagesimal, `2024-01-15` → date, `.inf`/`.nan` → float). Only JSON
207/// *strings* reach this function (numbers are emitted unquoted by
208/// `format_as_yaml`), so such a value must stay a string. The rule
209/// over-quotes some harmless values (e.g. IPv4 addresses), which is cheap and
210/// keeps it auditable.
211fn yaml_needs_quoting(s: &str) -> bool {
212    if s.is_empty() || s != s.trim() {
213        return true;
214    }
215    if s.chars()
216        .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
217    {
218        return true;
219    }
220    if matches!(
221        s.to_ascii_lowercase().as_str(),
222        "null" | "~" | "true" | "false" | "yes" | "no" | "y" | "n" | "on" | "off" | "=" | "<<"
223    ) {
224        return true;
225    }
226    if let Some(first) = s.chars().next() {
227        // A leading YAML indicator char makes the whole scalar non-plain.
228        if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
229            return true;
230        }
231        // Possible number / timestamp / special float (see doc comment).
232        if first.is_ascii_digit() || first == '+' || first == '.' {
233            return true;
234        }
235    }
236    s.contains(": ") || s.ends_with(':') || s.contains(" #")
237}
238
239/// Emits `s` as a YAML double-quoted scalar, escaping `"`, `\`, control
240/// characters, and the YAML line-break characters (so attacker ANSI/control
241/// bytes can't reach the terminal or break the document).
242fn yaml_quote(s: &str) -> String {
243    let mut out = String::with_capacity(s.len() + 2);
244    out.push('"');
245    for c in s.chars() {
246        match c {
247            '"' => out.push_str("\\\""),
248            '\\' => out.push_str("\\\\"),
249            '\n' => out.push_str("\\n"),
250            '\t' => out.push_str("\\t"),
251            '\r' => out.push_str("\\r"),
252            '\0' => out.push_str("\\0"),
253            // YAML line breaks: next line (a C1 control, so it must precede
254            // the generic arm below), line separator, paragraph separator.
255            '\u{0085}' => out.push_str("\\N"),
256            '\u{2028}' => out.push_str("\\L"),
257            '\u{2029}' => out.push_str("\\P"),
258            // C0/C1 controls + DEL → YAML \xXX escape (all <= 0x9F, two digits).
259            c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
260            c => out.push(c),
261        }
262    }
263    out.push('"');
264    out
265}
266
267/// Emits a scalar string as a plain YAML scalar when safe, else double-quoted.
268fn yaml_scalar(s: &str) -> String {
269    if yaml_needs_quoting(s) {
270        yaml_quote(s)
271    } else {
272        s.to_string()
273    }
274}
275
276/// Simple YAML-like formatter from serde_json::Value.
277fn format_as_yaml(value: &serde_json::Value, indent: usize) -> String {
278    let prefix = "  ".repeat(indent);
279    match value {
280        serde_json::Value::Null => "null".to_string(),
281        serde_json::Value::Bool(b) => b.to_string(),
282        serde_json::Value::Number(n) => n.to_string(),
283        serde_json::Value::String(s) => yaml_scalar(s),
284        serde_json::Value::Array(arr) => {
285            if arr.is_empty() {
286                return "[]".to_string();
287            }
288            let mut out = String::new();
289            for item in arr {
290                out.push('\n');
291                out.push_str(&prefix);
292                out.push_str("- ");
293                let formatted = format_as_yaml(item, indent + 1);
294                out.push_str(&formatted);
295            }
296            out
297        }
298        serde_json::Value::Object(map) => {
299            if map.is_empty() {
300                return "{}".to_string();
301            }
302            let mut out = String::new();
303            let mut first = indent == 0;
304            for (key, val) in map {
305                if !first {
306                    out.push('\n');
307                }
308                first = false;
309                out.push_str(&prefix);
310                // Keys can be attacker-controlled (e.g. RDAP `extra` flattened
311                // map), so quote them under the same rules as scalar values.
312                out.push_str(&yaml_scalar(key));
313                out.push_str(": ");
314                match val {
315                    serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
316                        out.push_str(&format_as_yaml(val, indent + 1));
317                    }
318                    _ => {
319                        out.push_str(&format_as_yaml(val, indent));
320                    }
321                }
322            }
323            out
324        }
325    }
326}
327
328pub fn get_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
329    match format {
330        OutputFormat::Human => Box::new(HumanFormatter::new()),
331        OutputFormat::Json => Box::new(JsonFormatter::new()),
332        OutputFormat::Yaml => Box::new(YamlFormatter::new()),
333        OutputFormat::Markdown => Box::new(MarkdownFormatter::new()),
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn test_output_format_from_str() {
343        assert_eq!(
344            "human".parse::<OutputFormat>().unwrap(),
345            OutputFormat::Human
346        );
347        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
348        assert_eq!("yaml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
349        assert_eq!("yml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
350        assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Human);
351        assert_eq!(
352            "pretty".parse::<OutputFormat>().unwrap(),
353            OutputFormat::Human
354        );
355        assert!("invalid".parse::<OutputFormat>().is_err());
356    }
357
358    #[test]
359    fn test_output_format_default() {
360        assert_eq!(OutputFormat::default(), OutputFormat::Human);
361    }
362
363    #[test]
364    fn test_get_formatter_returns_correct_type() {
365        // Just verify we can get formatters without panicking
366        let _ = get_formatter(OutputFormat::Human);
367        let _ = get_formatter(OutputFormat::Json);
368        let _ = get_formatter(OutputFormat::Yaml);
369    }
370
371    #[test]
372    fn test_yaml_formatter_basic() {
373        let formatter = YamlFormatter::new();
374        let status = crate::status::StatusResponse::new("example.com".to_string());
375        let output = formatter.format_status(&status);
376        assert!(output.contains("example.com"));
377        assert!(output.contains("domain"));
378    }
379
380    #[test]
381    fn test_format_as_yaml_primitives() {
382        assert_eq!(format_as_yaml(&serde_json::json!(null), 0), "null");
383        assert_eq!(format_as_yaml(&serde_json::json!(true), 0), "true");
384        assert_eq!(format_as_yaml(&serde_json::json!(42), 0), "42");
385        assert_eq!(format_as_yaml(&serde_json::json!("hello"), 0), "hello");
386    }
387
388    #[test]
389    fn test_format_as_yaml_array() {
390        let output = format_as_yaml(&serde_json::json!([1, 2, 3]), 0);
391        assert!(output.contains("- 1"));
392        assert!(output.contains("- 2"));
393        assert!(output.contains("- 3"));
394    }
395
396    #[test]
397    fn test_format_as_yaml_empty_collections() {
398        assert_eq!(format_as_yaml(&serde_json::json!([]), 0), "[]");
399        assert_eq!(format_as_yaml(&serde_json::json!({}), 0), "{}");
400    }
401
402    // --- #54: YAML scalar quoting hardening ----------------------------
403
404    #[test]
405    fn yaml_scalar_quoting_hardened() {
406        use serde_json::json;
407        let y = |s: &str| format_as_yaml(&json!(s), 0);
408
409        // YAML 1.1 bool/null tokens must be quoted to stay strings.
410        assert_eq!(y("No"), "\"No\"");
411        assert_eq!(y("true"), "\"true\"");
412        assert_eq!(y("null"), "\"null\"");
413        assert_eq!(y("~"), "\"~\"");
414
415        // Empty + leading/trailing whitespace.
416        assert_eq!(y(""), "\"\"");
417        assert_eq!(y(" leading"), "\" leading\"");
418        assert_eq!(y("trailing "), "\"trailing \"");
419
420        // Leading YAML indicator characters.
421        for s in [
422            "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&amp", "|pipe", ">gt",
423            "%pct", "`tick", "?q", ",comma",
424        ] {
425            assert_eq!(
426                y(s),
427                format!("\"{s}\""),
428                "must quote leading-indicator {s:?}"
429            );
430        }
431
432        // Colon-space breaks a plain scalar.
433        assert_eq!(y("key: value"), "\"key: value\"");
434
435        // Control chars / ANSI are escaped, never emitted raw.
436        assert_eq!(y("a\tb"), "\"a\\tb\"");
437        assert_eq!(y("a\rb"), "\"a\\rb\"");
438        assert_eq!(y("x\x1b[31m"), "\"x\\x1b[31m\"");
439
440        // Safe plain scalars must NOT be over-quoted.
441        assert_eq!(y("plain-value"), "plain-value");
442        assert_eq!(y("has internal spaces"), "has internal spaces");
443        assert_eq!(y("ns1.example.com"), "ns1.example.com");
444    }
445
446    #[test]
447    fn yaml_quotes_strings_a_resolver_would_read_as_non_strings() {
448        use serde_json::json;
449        let y = |s: &str| format_as_yaml(&json!(s), 0);
450
451        for s in [
452            "+1.5555550100",        // WHOIS phone → float 1.55555501
453            "292",                  // registrar IANA id string → int
454            "0123",                 // YAML 1.1 octal → 83
455            "1:20",                 // YAML 1.1 sexagesimal → 80
456            "2024-01-15",           // date
457            "2024-01-15T04:00:00Z", // RFC 3339 timestamp → datetime
458            ".inf",
459            ".NaN",
460            "=",  // YAML 1.1 value key: PyYAML raises ConstructorError
461            "<<", // merge key: PyYAML raises ConstructorError
462            "y",  // YAML 1.1 single-letter bools, any case
463            "N",
464        ] {
465            assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
466        }
467
468        // Hostnames and words stay plain; real JSON numbers are never quoted.
469        assert_eq!(y("ns1.example.com"), "ns1.example.com");
470        assert_eq!(y("yes-but-longer"), "yes-but-longer");
471        assert_eq!(y("a=b"), "a=b");
472        assert_eq!(format_as_yaml(&json!(292), 0), "292");
473        assert_eq!(format_as_yaml(&json!(1.5), 0), "1.5");
474    }
475
476    #[test]
477    fn yaml_escapes_unicode_line_breaks() {
478        use serde_json::json;
479        // U+2028/U+2029 aren't `char::is_control`, so they used to be emitted
480        // raw — and a raw one (or U+0085) makes the whole document fail to
481        // parse. They must be quoted and escaped with YAML's \L / \P / \N, in
482        // values and in keys alike.
483        let y = |s: &str| format_as_yaml(&json!(s), 0);
484        assert_eq!(y("a\u{2028}b"), "\"a\\Lb\"");
485        assert_eq!(y("a\u{2029}b"), "\"a\\Pb\"");
486        assert_eq!(y("a\u{0085}b"), "\"a\\Nb\"");
487
488        let doc = format_as_yaml(&json!({ "k\u{2028}ey": "v" }), 0);
489        assert_eq!(doc, "\"k\\Ley\": v");
490    }
491
492    #[test]
493    fn yaml_object_keys_with_significant_chars_are_quoted() {
494        // Keys from attacker-controlled flattened maps (e.g. RDAP `extra`) must
495        // be quoted too, or a key like "a\ninjected: true" forges structure.
496        let doc = format_as_yaml(&serde_json::json!({ "a\nb": "v" }), 0);
497        assert!(
498            doc.contains("\"a\\nb\""),
499            "malicious key must be quoted/escaped: {doc:?}"
500        );
501        // Normal identifier keys stay unquoted.
502        let doc = format_as_yaml(&serde_json::json!({ "domain": "x" }), 0);
503        assert!(
504            doc.starts_with("domain:"),
505            "plain key must not be quoted: {doc:?}"
506        );
507    }
508}