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