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