Skip to main content

seer_core/output/
mod.rs

1//! Output formatting for every seer-core result type.
2//!
3//! [`OutputFormatter`] has one `format_*` method per result type, implemented
4//! by the human (colored terminal), JSON, YAML and Markdown formatters. The
5//! method list is written once, in `with_report_methods!`, which generates the
6//! trait and all four impls.
7//! Callers pick one through [`get_formatter`] from an [`OutputFormat`], so the
8//! CLI, REPL, TUI raw view and clipboard copy all render identically. Human
9//! and Markdown output are pinned by `insta` snapshots in
10//! `seer-core/tests/format_snapshots.rs`.
11//!
12//! [`dig_short`] and [`dig_trace_short`] are the `dig +short` rendering of a
13//! query result and a trace: bare values, one per line, sanitized for a
14//! terminal — a plain-text mode outside the four formats. [`dig`] also holds
15//! the wording of a query's outcome (NXDOMAIN vs NODATA, the wildcard note),
16//! shared by the formatters and the TUI's DNS lens, and [`sanitize_line`] is
17//! the terminal-safety guard the human formatter applies to remote strings.
18//! Wording the formats share — [`availability_label`] (with its
19//! [`Emphasis`], also used by the TUI), the expiry phrase, the lookup source,
20//! the DNSSEC depth note, the propagation detail and the diff rows — is
21//! written once here and in `diff_table`.
22
23// The report-method list and the impl generators below must precede the
24// `mod` declarations: `macro_rules!` is textually scoped, and the human and
25// markdown modules invoke `impl_forwarding!` where their inherent methods
26// are visible.
27
28/// Calls `$mac!` with every report type a formatter renders, one
29/// `method(arg: Type);` row each. This list is the single source of truth for
30/// [`OutputFormatter`] and all four of its impls: adding a report type is one
31/// row here plus the human and markdown inherent methods of the same name.
32macro_rules! with_report_methods {
33    ($mac:ident!($($prefix:tt)*)) => {
34        $mac! {
35            $($prefix)*
36            format_whois(response: crate::whois::WhoisResponse);
37            format_rdap(response: crate::rdap::RdapResponse);
38            format_dns(records: [crate::dns::DnsRecord]);
39            format_dig(result: crate::dns::DnsQueryResult);
40            format_dns_trace(trace: crate::dns::DnsTrace);
41            format_propagation(result: crate::dns::PropagationResult);
42            format_lookup(result: crate::lookup::LookupResult);
43            format_status(response: crate::status::StatusResponse);
44            format_follow_iteration(iteration: crate::dns::FollowIteration);
45            format_follow(result: crate::dns::FollowResult);
46            format_availability(result: crate::availability::AvailabilityResult);
47            format_dnssec(report: crate::dns::DnssecReport);
48            format_delegation(report: crate::dns::DelegationReport);
49            format_tld(info: crate::tld::TldInfo);
50            format_dns_comparison(comparison: crate::dns::DnsComparison);
51            format_subdomains(result: crate::subdomains::SubdomainResult);
52            format_diff(diff: crate::diff::DomainDiff);
53            format_ssl(report: crate::ssl::SslReport);
54            format_watch(report: crate::watchlist::WatchReport);
55            format_domain_info(info: crate::domain_info::DomainInfo);
56            format_drift(report: crate::drift::DriftReport);
57            format_posture(posture: crate::posture::EmailPosture);
58            format_headers(report: crate::headers::HeaderReport);
59            format_takeover(report: crate::takeover::TakeoverReport);
60            format_caa(policy: crate::caa::CaaPolicy);
61            format_confusables(report: crate::confusables::ConfusableReport);
62            format_subdomain_classification(result: crate::subdomains::SubdomainClassification);
63            format_subdomain_baseline_diff(report: crate::subdomains::SubdomainBaselineDiff);
64        }
65    };
66}
67
68macro_rules! declare_output_formatter {
69    ($($method:ident($arg:ident: $ty:ty);)+) => {
70        /// Renders every report type in one output format; pick an
71        /// implementation with [`get_formatter`].
72        pub trait OutputFormatter {
73            $(fn $method(&self, $arg: &$ty) -> String;)+
74        }
75    };
76}
77
78/// Implements [`OutputFormatter`] for a data-format formatter by passing every
79/// report to one serializing method (`to_json`, `to_yaml_value`).
80macro_rules! impl_serializing {
81    ($formatter:ty, $render:ident; $($method:ident($arg:ident: $ty:ty);)+) => {
82        impl OutputFormatter for $formatter {
83            $(fn $method(&self, $arg: &$ty) -> String {
84                self.$render($arg)
85            })+
86        }
87    };
88}
89
90/// Implements [`OutputFormatter`] by forwarding each method to the inherent
91/// method of the same name, defined in the formatter's per-concern
92/// submodules. Rust resolves the inherent method first, so this does not
93/// recurse; it must be invoked where those inherent methods are visible.
94/// A missing inherent method would make the forwarder call itself, so
95/// `unconditional_recursion` is denied: a compile error, not a stack overflow.
96macro_rules! impl_forwarding {
97    ($formatter:ty; $($method:ident($arg:ident: $ty:ty);)+) => {
98        #[deny(unconditional_recursion)]
99        impl OutputFormatter for $formatter {
100            $(fn $method(&self, $arg: &$ty) -> String {
101                self.$method($arg)
102            })+
103        }
104    };
105}
106
107mod contact;
108mod diff_table;
109pub mod dig;
110mod grouping;
111mod human;
112mod json;
113mod markdown;
114
115pub use dig::{dig_short, dig_trace_short};
116pub use human::HumanFormatter;
117pub use json::JsonFormatter;
118pub use markdown::MarkdownFormatter;
119
120use chrono::{DateTime, TimeDelta, Utc};
121use serde::{Deserialize, Serialize};
122
123use crate::dns::{AuthenticationTier, PropagationResult, PropagationVerdict};
124use crate::lookup::LookupResult;
125use crate::subdomains::SubdomainStatus;
126use crate::takeover::TakeoverVerdict;
127
128static_regex! {
129    /// ANSI escape sequences in untrusted text: CSI, OSC (terminated by BEL
130    /// or ST — ESC is excluded from the payload so it can't over-consume
131    /// across sequences) and the two-byte forms.
132    ANSI_ESCAPE_RE = r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[A-Z@-_]";
133}
134
135/// Invisible Unicode format characters that reorder or hide text without
136/// being control characters: zero-width space/joiners and LRM/RLM
137/// (U+200B–U+200F), the bidi embeddings and overrides (U+202A–U+202E) and
138/// isolates (U+2066–U+2069), the Arabic letter mark (U+061C) and the BOM
139/// (U+FEFF). A "Trojan Source" override can make remote text read as
140/// something it isn't, so every renderer drops them.
141fn is_invisible_format(c: char) -> bool {
142    matches!(
143        c,
144        '\u{200B}'..='\u{200F}'
145            | '\u{202A}'..='\u{202E}'
146            | '\u{2066}'..='\u{2069}'
147            | '\u{061C}'
148            | '\u{FEFF}'
149    )
150}
151
152/// Sanitizes untrusted remote text (a WHOIS value, a record's data, a server
153/// error) for one line of a terminal: ANSI escape sequences, the remaining
154/// C0/C1 control characters (CR, backspace, BEL, stray ESC, DEL…) and
155/// invisible bidi/zero-width format characters are removed, and newlines,
156/// tabs and the Unicode line/paragraph separators are folded to spaces — so
157/// remote data can neither inject terminal escapes nor forge an extra row.
158/// The human formatter applies it to every remote value; other terminal
159/// renderers (the TUI) use it too.
160pub fn sanitize_line(s: &str) -> String {
161    ANSI_ESCAPE_RE
162        .replace_all(s, "")
163        .chars()
164        .filter_map(|c| match c {
165            '\n' | '\t' | '\u{2028}' | '\u{2029}' => Some(' '),
166            c if c.is_control() || is_invisible_format(c) => None,
167            c => Some(c),
168        })
169        .collect()
170}
171
172/// Whole days from now until `when` — see [`crate::dates::days_until`]: an
173/// expiry any time in the past is negative, so it never renders as
174/// "expires in 0 days".
175fn days_until(when: DateTime<Utc>) -> i64 {
176    crate::dates::days_until(when, Utc::now())
177}
178
179/// `1 day` / `N days`.
180fn day_count(days: i64) -> String {
181    if days == 1 {
182        "1 day".to_string()
183    } else {
184        format!("{days} days")
185    }
186}
187
188/// The one wording of an expiry `days_until` days away (see [`days_until`]):
189/// "expired N days ago" once past, else "expires in N days".
190fn expiry_phrase(days_until: i64) -> String {
191    if days_until < 0 {
192        format!("expired {} ago", day_count(-days_until))
193    } else {
194        format!("expires in {}", day_count(days_until))
195    }
196}
197
198/// How a renderer should emphasize a value: the human formatter maps it to a
199/// color, the TUI to a theme color.
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum Emphasis {
202    /// Healthy, or the answer hoped for.
203    Good,
204    /// A neutral fact.
205    Neutral,
206    /// Worth attention.
207    Caution,
208    /// A failure or an urgent problem.
209    Bad,
210}
211
212/// How urgent an expiry `days_until` days away is: already past or under 30
213/// days is [`Emphasis::Bad`], under 90 [`Emphasis::Caution`], else
214/// [`Emphasis::Good`].
215fn expiry_emphasis(days_until: i64) -> Emphasis {
216    match days_until {
217        d if d < 30 => Emphasis::Bad,
218        d if d < 90 => Emphasis::Caution,
219        _ => Emphasis::Good,
220    }
221}
222
223/// The display label of an availability verdict
224/// ([`crate::availability::AvailabilityResult::verdict`]) and how to
225/// emphasize it. Any other verdict reads "UNKNOWN".
226pub fn availability_label(verdict: &str) -> (&'static str, Emphasis) {
227    match verdict {
228        "available" => ("AVAILABLE", Emphasis::Good),
229        "likely_available" => ("MAY BE AVAILABLE", Emphasis::Caution),
230        "registered" => ("REGISTERED", Emphasis::Neutral),
231        "likely_registered" => ("LIKELY REGISTERED", Emphasis::Caution),
232        _ => ("UNKNOWN", Emphasis::Bad),
233    }
234}
235
236/// Where a lookup's data came from, as every formatter's "Source" line
237/// reads it.
238fn lookup_source(result: &LookupResult) -> &'static str {
239    match result {
240        LookupResult::Rdap { .. } => "RDAP",
241        LookupResult::Whois {
242            rdap_error: None, ..
243        } => "WHOIS",
244        LookupResult::Whois { .. }
245        | LookupResult::Available {
246            whois_data: Some(_),
247            ..
248        } => "WHOIS (RDAP unavailable)",
249        LookupResult::Available { .. } => "availability check (RDAP and WHOIS failed)",
250    }
251}
252
253/// The WHOIS-vs-DNS nameserver verdict every formatter prints for a
254/// WHOIS-only lookup ([`crate::DnsNameservers`]). Host names are remote text:
255/// callers pass this through their sanitizing writer.
256pub(crate) fn dns_delegation_verdict(ns: &crate::DnsNameservers) -> String {
257    if ns.in_sync {
258        return "matches WHOIS".to_string();
259    }
260    let mut parts = Vec::new();
261    if !ns.missing_from_whois.is_empty() {
262        parts.push(format!(
263            "delegated but not in WHOIS: {}",
264            ns.missing_from_whois.join(", ")
265        ));
266    }
267    if !ns.missing_from_dns.is_empty() {
268        parts.push(format!(
269            "in WHOIS but not delegated: {}",
270            ns.missing_from_dns.join(", ")
271        ));
272    }
273    format!("differs from WHOIS ({})", parts.join("; "))
274}
275
276/// Formats a [`TimeDelta`] as a compact duration (`Ns` / `Nm Ns` / `Nh Nm`).
277fn format_duration(duration: TimeDelta) -> String {
278    let total_secs = duration.num_seconds();
279    if total_secs < 60 {
280        format!("{total_secs}s")
281    } else if total_secs < 3600 {
282        format!("{}m {}s", total_secs / 60, total_secs % 60)
283    } else {
284        format!("{}h {}m", total_secs / 3600, (total_secs % 3600) / 60)
285    }
286}
287
288/// The DNSSEC disclosure (M12) that closes every block of DNS answers:
289/// seer's resolver does not validate DNSSEC, and UDP DNS is trivially
290/// spoofable. The human formatters print it as is, Markdown as a quote.
291const DNSSEC_NOTE: &str = "Note: DNS responses are not DNSSEC-validated";
292
293/// A DNSSEC report's verification depth, and the note saying what that
294/// depth does *not* establish (none for an unsigned zone: nothing was
295/// checked).
296fn dnssec_depth(tier: AuthenticationTier) -> (&'static str, Option<&'static str>) {
297    match tier {
298        AuthenticationTier::Unsigned => ("unsigned (no DNSSEC records)", None),
299        AuthenticationTier::DigestOnly => (
300            "digest-only (DS↔DNSKEY consistency)",
301            Some(
302                "Note: reflects DS/DNSKEY digest consistency only — RRSIG signatures, \
303                 validity periods, and the chain to the root are NOT cryptographically \
304                 verified.",
305            ),
306        ),
307    }
308}
309
310/// The detail after a propagation verdict: how many of the responding
311/// servers agree (`all 20 responding servers agree`).
312fn propagation_detail(result: &PropagationResult) -> String {
313    let responding = result.servers_responding;
314    match result.verdict() {
315        PropagationVerdict::NoAnswer => {
316            format!("none of the {} servers answered", result.servers_checked)
317        }
318        PropagationVerdict::Full if responding == 1 => {
319            "the one responding server answered".to_string()
320        }
321        PropagationVerdict::Full => format!("all {responding} responding servers agree"),
322        _ => format!(
323            "{} of {responding} responding servers agree",
324            result.servers_agreeing()
325        ),
326    }
327}
328
329/// A takeover finding's verdict as every renderer prints it (the human and
330/// markdown formatters and the TUI's takeover lens).
331pub fn takeover_label(verdict: TakeoverVerdict) -> &'static str {
332    match verdict {
333        TakeoverVerdict::Vulnerable => "VULNERABLE",
334        TakeoverVerdict::Potential => "potential",
335        TakeoverVerdict::Inconclusive => "inconclusive",
336        TakeoverVerdict::Safe => "safe",
337    }
338}
339
340/// A classified subdomain's status as the formatters print it.
341fn subdomain_status_label(status: SubdomainStatus) -> &'static str {
342    match status {
343        SubdomainStatus::Live => "live",
344        SubdomainStatus::Dead => "dead",
345        SubdomainStatus::Wildcard => "wildcard",
346        SubdomainStatus::Unknown => "unknown",
347    }
348}
349
350/// A differing propagation server's answer as one short line of plain
351/// text (the caller escapes it for its format; the TUI through
352/// [`sanitize_line`]): the values themselves when
353/// the sets are small, else what it lacks and adds against the consensus —
354/// a 19-record TXT set is unreadable inline, and the difference is the news.
355pub fn propagation_difference(inc: &crate::dns::Inconsistency, empty_label: &str) -> String {
356    const INLINE_VALUES: usize = 6;
357    if inc.values.is_empty() {
358        return format!("no records ({empty_label})");
359    }
360    if inc.values.len() + inc.consensus.len() <= INLINE_VALUES {
361        return inc.values.join(", ");
362    }
363    let mut parts = Vec::new();
364    let missing = inc.missing_count();
365    if missing > 0 {
366        parts.push(format!("missing {missing} of {}", inc.consensus.len()));
367    }
368    let extra = inc.extra_values();
369    if !extra.is_empty() {
370        parts.push(format!("extra: {}", extra.join(", ")));
371    }
372    parts.join("; ")
373}
374
375/// Shown beside a propagation result whose answers look location-dependent
376/// ([`crate::dns::PropagationResult::looks_location_dependent`]).
377const GEO_NOTE: &str = "Note: answers vary by resolver location, typical of GeoDNS/CDN \
378     names rather than a propagation delay";
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
381#[serde(rename_all = "lowercase")]
382pub enum OutputFormat {
383    #[default]
384    Human,
385    Json,
386    Yaml,
387    Markdown,
388}
389
390impl std::str::FromStr for OutputFormat {
391    type Err = String;
392
393    fn from_str(s: &str) -> Result<Self, Self::Err> {
394        match s.to_lowercase().as_str() {
395            "human" | "text" | "pretty" => Ok(OutputFormat::Human),
396            "json" => Ok(OutputFormat::Json),
397            "yaml" | "yml" => Ok(OutputFormat::Yaml),
398            "markdown" | "md" => Ok(OutputFormat::Markdown),
399            _ => Err(format!(
400                "Unknown output format: {}. Use: human, json, yaml, markdown",
401                s
402            )),
403        }
404    }
405}
406
407with_report_methods!(declare_output_formatter!());
408
409/// YAML output formatter that converts data structures to YAML format.
410#[derive(Default)]
411pub struct YamlFormatter;
412
413impl YamlFormatter {
414    pub fn new() -> Self {
415        Self
416    }
417
418    /// Formats any serializable value as YAML output.
419    pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
420        // Convert to JSON value first, then format as YAML-like output
421        match serde_json::to_value(value) {
422            Ok(v) => format_as_yaml(&v, 0),
423            Err(e) => format!("error: {}", yaml_scalar(&e.to_string())),
424        }
425    }
426}
427
428with_report_methods!(impl_serializing!(YamlFormatter, to_yaml_value;));
429
430/// Returns true when a string cannot be emitted as a YAML *plain* scalar and
431/// must be double-quoted. Covers the cases the old `contains('\n'|':'|'#')`
432/// predicate missed (issue #54): empty strings, leading/trailing whitespace,
433/// any control character or YAML line break (U+0085/U+2028/U+2029 — a raw one
434/// makes the whole document unparseable), a leading YAML indicator character,
435/// an interior `": "` / trailing `:` / `" #"` (which break a plain scalar),
436/// and the YAML 1.1 bool/null tokens (`null`, `~`, `true`, `false`, `yes`,
437/// `no`, `y`, `n`, `on`, `off`) and special keys (`=` value, `<<` merge)
438/// which would otherwise round-trip as the wrong type or fail to load.
439///
440/// Anything starting with an ASCII digit, `+`, or `.` is quoted too: that is
441/// how a YAML 1.1/1.2 resolver comes to read a plain scalar as a number or
442/// timestamp (`+1.5555550100` → float, `292` → int, `0123` → octal, `1:20` →
443/// sexagesimal, `2024-01-15` → date, `.inf`/`.nan` → float). Only JSON
444/// *strings* reach this function (numbers are emitted unquoted by
445/// `format_as_yaml`), so such a value must stay a string. The rule
446/// over-quotes some harmless values (e.g. IPv4 addresses), which is cheap and
447/// keeps it auditable.
448fn yaml_needs_quoting(s: &str) -> bool {
449    if s.is_empty() || s != s.trim() {
450        return true;
451    }
452    if s.chars()
453        .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
454    {
455        return true;
456    }
457    if matches!(
458        s.to_ascii_lowercase().as_str(),
459        "null" | "~" | "true" | "false" | "yes" | "no" | "y" | "n" | "on" | "off" | "=" | "<<"
460    ) {
461        return true;
462    }
463    if let Some(first) = s.chars().next() {
464        // A leading YAML indicator char makes the whole scalar non-plain.
465        if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
466            return true;
467        }
468        // Possible number / timestamp / special float (see doc comment).
469        if first.is_ascii_digit() || first == '+' || first == '.' {
470            return true;
471        }
472    }
473    s.contains(": ") || s.ends_with(':') || s.contains(" #")
474}
475
476/// Emits `s` as a YAML double-quoted scalar, escaping `"`, `\`, control
477/// characters, and the YAML line-break characters (so attacker ANSI/control
478/// bytes can't reach the terminal or break the document).
479fn yaml_quote(s: &str) -> String {
480    let mut out = String::with_capacity(s.len() + 2);
481    out.push('"');
482    for c in s.chars() {
483        match c {
484            '"' => out.push_str("\\\""),
485            '\\' => out.push_str("\\\\"),
486            '\n' => out.push_str("\\n"),
487            '\t' => out.push_str("\\t"),
488            '\r' => out.push_str("\\r"),
489            '\0' => out.push_str("\\0"),
490            // YAML line breaks: next line (a C1 control, so it must precede
491            // the generic arm below), line separator, paragraph separator.
492            '\u{0085}' => out.push_str("\\N"),
493            '\u{2028}' => out.push_str("\\L"),
494            '\u{2029}' => out.push_str("\\P"),
495            // C0/C1 controls + DEL → YAML \xXX escape (all <= 0x9F, two digits).
496            c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
497            c => out.push(c),
498        }
499    }
500    out.push('"');
501    out
502}
503
504/// Emits a scalar string as a plain YAML scalar when safe, else double-quoted.
505fn yaml_scalar(s: &str) -> String {
506    if yaml_needs_quoting(s) {
507        yaml_quote(s)
508    } else {
509        s.to_string()
510    }
511}
512
513/// True for a non-empty array or object: a value rendered as an indented
514/// block rather than inline after its key or dash.
515fn is_yaml_block(value: &serde_json::Value) -> bool {
516    match value {
517        serde_json::Value::Array(arr) => !arr.is_empty(),
518        serde_json::Value::Object(map) => !map.is_empty(),
519        _ => false,
520    }
521}
522
523/// Block-style YAML from a `serde_json::Value`. A scalar or empty collection
524/// renders inline (`42`, `[]`); a non-empty collection renders as lines that
525/// each start with `indent` levels of two spaces, no leading or trailing
526/// blank. A block item's first line goes right after its `- ` (the dash
527/// takes the place of that line's own indent), and a key whose value is a
528/// block ends its line at the `:`, so no line carries trailing whitespace.
529fn format_as_yaml(value: &serde_json::Value, indent: usize) -> String {
530    let prefix = "  ".repeat(indent);
531    match value {
532        serde_json::Value::Null => "null".to_string(),
533        serde_json::Value::Bool(b) => b.to_string(),
534        serde_json::Value::Number(n) => n.to_string(),
535        serde_json::Value::String(s) => yaml_scalar(s),
536        serde_json::Value::Array(arr) if arr.is_empty() => "[]".to_string(),
537        serde_json::Value::Object(map) if map.is_empty() => "{}".to_string(),
538        serde_json::Value::Array(arr) => {
539            let item_prefix = "  ".repeat(indent + 1);
540            arr.iter()
541                .map(|item| {
542                    let rendered = format_as_yaml(item, indent + 1);
543                    let first_line = rendered.strip_prefix(&item_prefix).unwrap_or(&rendered);
544                    format!("{prefix}- {first_line}")
545                })
546                .collect::<Vec<_>>()
547                .join("\n")
548        }
549        serde_json::Value::Object(map) => map
550            .iter()
551            .map(|(key, val)| {
552                // Keys can be attacker-controlled (e.g. RDAP `extra` flattened
553                // map), so quote them under the same rules as scalar values.
554                let key = yaml_scalar(key);
555                if is_yaml_block(val) {
556                    format!("{prefix}{key}:\n{}", format_as_yaml(val, indent + 1))
557                } else {
558                    format!("{prefix}{key}: {}", format_as_yaml(val, indent))
559                }
560            })
561            .collect::<Vec<_>>()
562            .join("\n"),
563    }
564}
565
566pub fn get_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
567    match format {
568        OutputFormat::Human => Box::new(HumanFormatter::new()),
569        OutputFormat::Json => Box::new(JsonFormatter::new()),
570        OutputFormat::Yaml => Box::new(YamlFormatter::new()),
571        OutputFormat::Markdown => Box::new(MarkdownFormatter::new()),
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    #[test]
580    fn test_output_format_from_str() {
581        assert_eq!(
582            "human".parse::<OutputFormat>().unwrap(),
583            OutputFormat::Human
584        );
585        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
586        assert_eq!("yaml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
587        assert_eq!("yml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
588        assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Human);
589        assert_eq!(
590            "pretty".parse::<OutputFormat>().unwrap(),
591            OutputFormat::Human
592        );
593        assert!("invalid".parse::<OutputFormat>().is_err());
594    }
595
596    #[test]
597    fn test_output_format_default() {
598        assert_eq!(OutputFormat::default(), OutputFormat::Human);
599    }
600
601    #[test]
602    fn test_get_formatter_returns_correct_type() {
603        // Just verify we can get formatters without panicking
604        let _ = get_formatter(OutputFormat::Human);
605        let _ = get_formatter(OutputFormat::Json);
606        let _ = get_formatter(OutputFormat::Yaml);
607    }
608
609    #[test]
610    fn test_yaml_formatter_basic() {
611        let formatter = YamlFormatter::new();
612        let status = crate::status::StatusResponse::new("example.com".to_string());
613        let output = formatter.format_status(&status);
614        assert!(output.contains("example.com"));
615        assert!(output.contains("domain"));
616    }
617
618    #[test]
619    fn test_format_as_yaml_primitives() {
620        assert_eq!(format_as_yaml(&serde_json::json!(null), 0), "null");
621        assert_eq!(format_as_yaml(&serde_json::json!(true), 0), "true");
622        assert_eq!(format_as_yaml(&serde_json::json!(42), 0), "42");
623        assert_eq!(format_as_yaml(&serde_json::json!("hello"), 0), "hello");
624    }
625
626    #[test]
627    fn test_format_as_yaml_array() {
628        let output = format_as_yaml(&serde_json::json!([1, 2, 3]), 0);
629        assert!(output.contains("- 1"));
630        assert!(output.contains("- 2"));
631        assert!(output.contains("- 3"));
632    }
633
634    #[test]
635    fn yaml_is_clean_block_style_without_blank_or_trailing_space_lines() {
636        use serde_json::json;
637        // A top-level array used to start with a blank line, and an object
638        // inside an array rendered as `- ` (trailing space) then its keys.
639        let doc = format_as_yaml(
640            &json!([
641                {"name": "a", "ttl": 300},
642                {"name": "b", "tags": ["x", "z"], "empty": [], "meta": {"k": "v"}},
643                [1, 2]
644            ]),
645            0,
646        );
647        let expected = "\
648- name: a
649  ttl: 300
650- empty: []
651  meta:
652    k: v
653  name: b
654  tags:
655    - x
656    - z
657- - 1
658  - 2";
659        assert_eq!(doc, expected);
660        for line in doc.lines() {
661            assert!(!line.ends_with(' '), "trailing space: {line:?}");
662        }
663
664        let doc = format_as_yaml(&json!({"domain": "x", "records": [{"a": 1}]}), 0);
665        assert_eq!(doc, "domain: x\nrecords:\n  - a: 1");
666    }
667
668    #[test]
669    fn sanitize_line_strips_controls_and_folds_line_breaks() {
670        // Bare C0/C1 controls (CR, backspace, BEL, C1 CSI 0x9B) could
671        // overwrite or spoof rendered lines; newline/tab/U+2028 would forge
672        // a row.
673        assert_eq!(sanitize_line("a\rb\x08c\x07d\u{009b}e"), "abcde");
674        assert_eq!(
675            sanitize_line("line1\nline2\tcol\u{2028}x"),
676            "line1 line2 col x"
677        );
678        // OSC terminated by ST (ESC \) and classic CSI are removed whole.
679        assert_eq!(
680            sanitize_line("\x1b]0;malicious title\x1b\\visible"),
681            "visible"
682        );
683        assert_eq!(sanitize_line("\x1b[31mred\x1b[0m"), "red");
684    }
685
686    #[test]
687    fn sanitize_line_drops_bidi_and_zero_width_format_chars() {
688        // A right-to-left override (Trojan Source) or zero-width space
689        // passed through, so remote text could read as something else.
690        let trojan = "a\u{202E}b\u{2066}c\u{200B}d\u{FEFF}e\u{061C}f\u{200E}g\u{2069}";
691        assert_eq!(sanitize_line(trojan), "abcdefg");
692        // Ordinary non-ASCII text is kept.
693        assert_eq!(sanitize_line("café — ü"), "café — ü");
694    }
695
696    #[test]
697    fn expiry_phrase_is_one_wording_for_past_and_future() {
698        assert_eq!(expiry_phrase(-5), "expired 5 days ago");
699        assert_eq!(expiry_phrase(-1), "expired 1 day ago");
700        assert_eq!(expiry_phrase(0), "expires in 0 days");
701        assert_eq!(expiry_phrase(1), "expires in 1 day");
702        assert_eq!(expiry_phrase(29), "expires in 29 days");
703        assert_eq!(expiry_emphasis(-1), Emphasis::Bad);
704        assert_eq!(expiry_emphasis(29), Emphasis::Bad);
705        assert_eq!(expiry_emphasis(30), Emphasis::Caution);
706        assert_eq!(expiry_emphasis(90), Emphasis::Good);
707    }
708
709    #[test]
710    fn availability_label_covers_every_verdict_and_tolerates_others() {
711        assert_eq!(availability_label("available").0, "AVAILABLE");
712        assert_eq!(availability_label("likely_available").0, "MAY BE AVAILABLE");
713        assert_eq!(availability_label("registered").0, "REGISTERED");
714        assert_eq!(
715            availability_label("likely_registered").0,
716            "LIKELY REGISTERED"
717        );
718        assert_eq!(availability_label("unknown"), ("UNKNOWN", Emphasis::Bad));
719        assert_eq!(availability_label("whatever"), ("UNKNOWN", Emphasis::Bad));
720    }
721
722    #[test]
723    fn propagation_detail_names_a_lone_responder() {
724        use crate::dns::{
725            ConsensusValue, DnsRecord, DnsServer, DnsStatus, PropagationServerResult, RecordData,
726            RecordType,
727        };
728        let answered = PropagationServerResult {
729            server: DnsServer::new("Google", "8.8.8.8", "NA", "Google"),
730            records: vec![DnsRecord {
731                name: "example.com".into(),
732                record_type: RecordType::A,
733                ttl: 300,
734                data: RecordData::A {
735                    address: "1.2.3.4".into(),
736                },
737            }],
738            response_time_ms: 12,
739            success: true,
740            error: None,
741            status: Some(DnsStatus::NoError),
742        };
743        let result = PropagationResult {
744            domain: "example.com".into(),
745            record_type: RecordType::A,
746            servers_checked: 2,
747            servers_responding: 1,
748            propagation_percentage: 100.0,
749            results: vec![answered],
750            consensus_values: vec![ConsensusValue::new(RecordType::A, "1.2.3.4")],
751            inconsistencies: vec![],
752            unreachable_servers: vec![],
753            dnssec_validated: false,
754            nameserver_details: None,
755        };
756        assert_eq!(
757            propagation_detail(&result),
758            "the one responding server answered"
759        );
760        // Markdown used to say "all 1 responding servers agree".
761        let md = MarkdownFormatter::new().format_propagation(&result);
762        assert!(md.contains("the one responding server answered"), "{md}");
763    }
764
765    #[test]
766    fn dnssec_depth_notes_what_each_tier_does_not_verify() {
767        assert_eq!(dnssec_depth(AuthenticationTier::Unsigned).1, None);
768        let digest = dnssec_depth(AuthenticationTier::DigestOnly).1.unwrap();
769        assert!(digest.contains("RRSIG signatures, validity periods"));
770    }
771
772    #[test]
773    fn test_format_as_yaml_empty_collections() {
774        assert_eq!(format_as_yaml(&serde_json::json!([]), 0), "[]");
775        assert_eq!(format_as_yaml(&serde_json::json!({}), 0), "{}");
776    }
777
778    // --- #54: YAML scalar quoting hardening ----------------------------
779
780    #[test]
781    fn yaml_scalar_quoting_hardened() {
782        use serde_json::json;
783        let y = |s: &str| format_as_yaml(&json!(s), 0);
784
785        // YAML 1.1 bool/null tokens must be quoted to stay strings.
786        assert_eq!(y("No"), "\"No\"");
787        assert_eq!(y("true"), "\"true\"");
788        assert_eq!(y("null"), "\"null\"");
789        assert_eq!(y("~"), "\"~\"");
790
791        // Empty + leading/trailing whitespace.
792        assert_eq!(y(""), "\"\"");
793        assert_eq!(y(" leading"), "\" leading\"");
794        assert_eq!(y("trailing "), "\"trailing \"");
795
796        // Leading YAML indicator characters.
797        for s in [
798            "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&amp", "|pipe", ">gt",
799            "%pct", "`tick", "?q", ",comma",
800        ] {
801            assert_eq!(
802                y(s),
803                format!("\"{s}\""),
804                "must quote leading-indicator {s:?}"
805            );
806        }
807
808        // Colon-space breaks a plain scalar.
809        assert_eq!(y("key: value"), "\"key: value\"");
810
811        // Control chars / ANSI are escaped, never emitted raw.
812        assert_eq!(y("a\tb"), "\"a\\tb\"");
813        assert_eq!(y("a\rb"), "\"a\\rb\"");
814        assert_eq!(y("x\x1b[31m"), "\"x\\x1b[31m\"");
815
816        // Safe plain scalars must NOT be over-quoted.
817        assert_eq!(y("plain-value"), "plain-value");
818        assert_eq!(y("has internal spaces"), "has internal spaces");
819        assert_eq!(y("ns1.example.com"), "ns1.example.com");
820    }
821
822    #[test]
823    fn yaml_quotes_strings_a_resolver_would_read_as_non_strings() {
824        use serde_json::json;
825        let y = |s: &str| format_as_yaml(&json!(s), 0);
826
827        for s in [
828            "+1.5555550100",        // WHOIS phone → float 1.55555501
829            "292",                  // registrar IANA id string → int
830            "0123",                 // YAML 1.1 octal → 83
831            "1:20",                 // YAML 1.1 sexagesimal → 80
832            "2024-01-15",           // date
833            "2024-01-15T04:00:00Z", // RFC 3339 timestamp → datetime
834            ".inf",
835            ".NaN",
836            "=",  // YAML 1.1 value key: PyYAML raises ConstructorError
837            "<<", // merge key: PyYAML raises ConstructorError
838            "y",  // YAML 1.1 single-letter bools, any case
839            "N",
840        ] {
841            assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
842        }
843
844        // Hostnames and words stay plain; real JSON numbers are never quoted.
845        assert_eq!(y("ns1.example.com"), "ns1.example.com");
846        assert_eq!(y("yes-but-longer"), "yes-but-longer");
847        assert_eq!(y("a=b"), "a=b");
848        assert_eq!(format_as_yaml(&json!(292), 0), "292");
849        assert_eq!(format_as_yaml(&json!(1.5), 0), "1.5");
850    }
851
852    #[test]
853    fn yaml_escapes_unicode_line_breaks() {
854        use serde_json::json;
855        // U+2028/U+2029 aren't `char::is_control`, so they used to be emitted
856        // raw — and a raw one (or U+0085) makes the whole document fail to
857        // parse. They must be quoted and escaped with YAML's \L / \P / \N, in
858        // values and in keys alike.
859        let y = |s: &str| format_as_yaml(&json!(s), 0);
860        assert_eq!(y("a\u{2028}b"), "\"a\\Lb\"");
861        assert_eq!(y("a\u{2029}b"), "\"a\\Pb\"");
862        assert_eq!(y("a\u{0085}b"), "\"a\\Nb\"");
863
864        let doc = format_as_yaml(&json!({ "k\u{2028}ey": "v" }), 0);
865        assert_eq!(doc, "\"k\\Ley\": v");
866    }
867
868    #[test]
869    fn yaml_object_keys_with_significant_chars_are_quoted() {
870        // Keys from attacker-controlled flattened maps (e.g. RDAP `extra`) must
871        // be quoted too, or a key like "a\ninjected: true" forges structure.
872        let doc = format_as_yaml(&serde_json::json!({ "a\nb": "v" }), 0);
873        assert!(
874            doc.contains("\"a\\nb\""),
875            "malicious key must be quoted/escaped: {doc:?}"
876        );
877        // Normal identifier keys stay unquoted.
878        let doc = format_as_yaml(&serde_json::json!({ "domain": "x" }), 0);
879        assert!(
880            doc.starts_with("domain:"),
881            "plain key must not be quoted: {doc:?}"
882        );
883    }
884}