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