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