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