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/// Formats a [`TimeDelta`] as a compact duration (`Ns` / `Nm Ns` / `Nh Nm`).
254fn format_duration(duration: TimeDelta) -> String {
255    let total_secs = duration.num_seconds();
256    if total_secs < 60 {
257        format!("{total_secs}s")
258    } else if total_secs < 3600 {
259        format!("{}m {}s", total_secs / 60, total_secs % 60)
260    } else {
261        format!("{}h {}m", total_secs / 3600, (total_secs % 3600) / 60)
262    }
263}
264
265/// The DNSSEC disclosure (M12) that closes every block of DNS answers:
266/// seer's resolver does not validate DNSSEC, and UDP DNS is trivially
267/// spoofable. The human formatters print it as is, Markdown as a quote.
268const DNSSEC_NOTE: &str = "Note: DNS responses are not DNSSEC-validated";
269
270/// A DNSSEC report's verification depth, and the note saying what that
271/// depth does *not* establish (none for an unsigned zone: nothing was
272/// checked).
273fn dnssec_depth(tier: AuthenticationTier) -> (&'static str, Option<&'static str>) {
274    match tier {
275        AuthenticationTier::Unsigned => ("unsigned (no DNSSEC records)", None),
276        AuthenticationTier::DigestOnly => (
277            "digest-only (DS↔DNSKEY consistency)",
278            Some(
279                "Note: reflects DS/DNSKEY digest consistency only — RRSIG signatures, \
280                 validity periods, and the chain to the root are NOT cryptographically \
281                 verified.",
282            ),
283        ),
284    }
285}
286
287/// The detail after a propagation verdict: how many of the responding
288/// servers agree (`all 20 responding servers agree`).
289fn propagation_detail(result: &PropagationResult) -> String {
290    let responding = result.servers_responding;
291    match result.verdict() {
292        PropagationVerdict::NoAnswer => {
293            format!("none of the {} servers answered", result.servers_checked)
294        }
295        PropagationVerdict::Full if responding == 1 => {
296            "the one responding server answered".to_string()
297        }
298        PropagationVerdict::Full => format!("all {responding} responding servers agree"),
299        _ => format!(
300            "{} of {responding} responding servers agree",
301            result.servers_agreeing()
302        ),
303    }
304}
305
306/// A takeover finding's verdict as every renderer prints it (the human and
307/// markdown formatters and the TUI's takeover lens).
308pub fn takeover_label(verdict: TakeoverVerdict) -> &'static str {
309    match verdict {
310        TakeoverVerdict::Vulnerable => "VULNERABLE",
311        TakeoverVerdict::Potential => "potential",
312        TakeoverVerdict::Inconclusive => "inconclusive",
313        TakeoverVerdict::Safe => "safe",
314    }
315}
316
317/// A classified subdomain's status as the formatters print it.
318fn subdomain_status_label(status: SubdomainStatus) -> &'static str {
319    match status {
320        SubdomainStatus::Live => "live",
321        SubdomainStatus::Dead => "dead",
322        SubdomainStatus::Wildcard => "wildcard",
323        SubdomainStatus::Unknown => "unknown",
324    }
325}
326
327/// A differing propagation server's answer as one short line of plain
328/// text (the caller escapes it for its format; the TUI through
329/// [`sanitize_line`]): the values themselves when
330/// the sets are small, else what it lacks and adds against the consensus —
331/// a 19-record TXT set is unreadable inline, and the difference is the news.
332pub fn propagation_difference(inc: &crate::dns::Inconsistency, empty_label: &str) -> String {
333    const INLINE_VALUES: usize = 6;
334    if inc.values.is_empty() {
335        return format!("no records ({empty_label})");
336    }
337    if inc.values.len() + inc.consensus.len() <= INLINE_VALUES {
338        return inc.values.join(", ");
339    }
340    let mut parts = Vec::new();
341    let missing = inc.missing_count();
342    if missing > 0 {
343        parts.push(format!("missing {missing} of {}", inc.consensus.len()));
344    }
345    let extra = inc.extra_values();
346    if !extra.is_empty() {
347        parts.push(format!("extra: {}", extra.join(", ")));
348    }
349    parts.join("; ")
350}
351
352/// Shown beside a propagation result whose answers look location-dependent
353/// ([`crate::dns::PropagationResult::looks_location_dependent`]).
354const GEO_NOTE: &str = "Note: answers vary by resolver location, typical of GeoDNS/CDN \
355     names rather than a propagation delay";
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
358#[serde(rename_all = "lowercase")]
359pub enum OutputFormat {
360    #[default]
361    Human,
362    Json,
363    Yaml,
364    Markdown,
365}
366
367impl std::str::FromStr for OutputFormat {
368    type Err = String;
369
370    fn from_str(s: &str) -> Result<Self, Self::Err> {
371        match s.to_lowercase().as_str() {
372            "human" | "text" | "pretty" => Ok(OutputFormat::Human),
373            "json" => Ok(OutputFormat::Json),
374            "yaml" | "yml" => Ok(OutputFormat::Yaml),
375            "markdown" | "md" => Ok(OutputFormat::Markdown),
376            _ => Err(format!(
377                "Unknown output format: {}. Use: human, json, yaml, markdown",
378                s
379            )),
380        }
381    }
382}
383
384with_report_methods!(declare_output_formatter!());
385
386/// YAML output formatter that converts data structures to YAML format.
387#[derive(Default)]
388pub struct YamlFormatter;
389
390impl YamlFormatter {
391    pub fn new() -> Self {
392        Self
393    }
394
395    /// Formats any serializable value as YAML output.
396    pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
397        // Convert to JSON value first, then format as YAML-like output
398        match serde_json::to_value(value) {
399            Ok(v) => format_as_yaml(&v, 0),
400            Err(e) => format!("error: {}", yaml_scalar(&e.to_string())),
401        }
402    }
403}
404
405with_report_methods!(impl_serializing!(YamlFormatter, to_yaml_value;));
406
407/// Returns true when a string cannot be emitted as a YAML *plain* scalar and
408/// must be double-quoted. Covers the cases the old `contains('\n'|':'|'#')`
409/// predicate missed (issue #54): empty strings, leading/trailing whitespace,
410/// any control character or YAML line break (U+0085/U+2028/U+2029 — a raw one
411/// makes the whole document unparseable), a leading YAML indicator character,
412/// an interior `": "` / trailing `:` / `" #"` (which break a plain scalar),
413/// and the YAML 1.1 bool/null tokens (`null`, `~`, `true`, `false`, `yes`,
414/// `no`, `y`, `n`, `on`, `off`) and special keys (`=` value, `<<` merge)
415/// which would otherwise round-trip as the wrong type or fail to load.
416///
417/// Anything starting with an ASCII digit, `+`, or `.` is quoted too: that is
418/// how a YAML 1.1/1.2 resolver comes to read a plain scalar as a number or
419/// timestamp (`+1.5555550100` → float, `292` → int, `0123` → octal, `1:20` →
420/// sexagesimal, `2024-01-15` → date, `.inf`/`.nan` → float). Only JSON
421/// *strings* reach this function (numbers are emitted unquoted by
422/// `format_as_yaml`), so such a value must stay a string. The rule
423/// over-quotes some harmless values (e.g. IPv4 addresses), which is cheap and
424/// keeps it auditable.
425fn yaml_needs_quoting(s: &str) -> bool {
426    if s.is_empty() || s != s.trim() {
427        return true;
428    }
429    if s.chars()
430        .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
431    {
432        return true;
433    }
434    if matches!(
435        s.to_ascii_lowercase().as_str(),
436        "null" | "~" | "true" | "false" | "yes" | "no" | "y" | "n" | "on" | "off" | "=" | "<<"
437    ) {
438        return true;
439    }
440    if let Some(first) = s.chars().next() {
441        // A leading YAML indicator char makes the whole scalar non-plain.
442        if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
443            return true;
444        }
445        // Possible number / timestamp / special float (see doc comment).
446        if first.is_ascii_digit() || first == '+' || first == '.' {
447            return true;
448        }
449    }
450    s.contains(": ") || s.ends_with(':') || s.contains(" #")
451}
452
453/// Emits `s` as a YAML double-quoted scalar, escaping `"`, `\`, control
454/// characters, and the YAML line-break characters (so attacker ANSI/control
455/// bytes can't reach the terminal or break the document).
456fn yaml_quote(s: &str) -> String {
457    let mut out = String::with_capacity(s.len() + 2);
458    out.push('"');
459    for c in s.chars() {
460        match c {
461            '"' => out.push_str("\\\""),
462            '\\' => out.push_str("\\\\"),
463            '\n' => out.push_str("\\n"),
464            '\t' => out.push_str("\\t"),
465            '\r' => out.push_str("\\r"),
466            '\0' => out.push_str("\\0"),
467            // YAML line breaks: next line (a C1 control, so it must precede
468            // the generic arm below), line separator, paragraph separator.
469            '\u{0085}' => out.push_str("\\N"),
470            '\u{2028}' => out.push_str("\\L"),
471            '\u{2029}' => out.push_str("\\P"),
472            // C0/C1 controls + DEL → YAML \xXX escape (all <= 0x9F, two digits).
473            c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
474            c => out.push(c),
475        }
476    }
477    out.push('"');
478    out
479}
480
481/// Emits a scalar string as a plain YAML scalar when safe, else double-quoted.
482fn yaml_scalar(s: &str) -> String {
483    if yaml_needs_quoting(s) {
484        yaml_quote(s)
485    } else {
486        s.to_string()
487    }
488}
489
490/// True for a non-empty array or object: a value rendered as an indented
491/// block rather than inline after its key or dash.
492fn is_yaml_block(value: &serde_json::Value) -> bool {
493    match value {
494        serde_json::Value::Array(arr) => !arr.is_empty(),
495        serde_json::Value::Object(map) => !map.is_empty(),
496        _ => false,
497    }
498}
499
500/// Block-style YAML from a `serde_json::Value`. A scalar or empty collection
501/// renders inline (`42`, `[]`); a non-empty collection renders as lines that
502/// each start with `indent` levels of two spaces, no leading or trailing
503/// blank. A block item's first line goes right after its `- ` (the dash
504/// takes the place of that line's own indent), and a key whose value is a
505/// block ends its line at the `:`, so no line carries trailing whitespace.
506fn format_as_yaml(value: &serde_json::Value, indent: usize) -> String {
507    let prefix = "  ".repeat(indent);
508    match value {
509        serde_json::Value::Null => "null".to_string(),
510        serde_json::Value::Bool(b) => b.to_string(),
511        serde_json::Value::Number(n) => n.to_string(),
512        serde_json::Value::String(s) => yaml_scalar(s),
513        serde_json::Value::Array(arr) if arr.is_empty() => "[]".to_string(),
514        serde_json::Value::Object(map) if map.is_empty() => "{}".to_string(),
515        serde_json::Value::Array(arr) => {
516            let item_prefix = "  ".repeat(indent + 1);
517            arr.iter()
518                .map(|item| {
519                    let rendered = format_as_yaml(item, indent + 1);
520                    let first_line = rendered.strip_prefix(&item_prefix).unwrap_or(&rendered);
521                    format!("{prefix}- {first_line}")
522                })
523                .collect::<Vec<_>>()
524                .join("\n")
525        }
526        serde_json::Value::Object(map) => map
527            .iter()
528            .map(|(key, val)| {
529                // Keys can be attacker-controlled (e.g. RDAP `extra` flattened
530                // map), so quote them under the same rules as scalar values.
531                let key = yaml_scalar(key);
532                if is_yaml_block(val) {
533                    format!("{prefix}{key}:\n{}", format_as_yaml(val, indent + 1))
534                } else {
535                    format!("{prefix}{key}: {}", format_as_yaml(val, indent))
536                }
537            })
538            .collect::<Vec<_>>()
539            .join("\n"),
540    }
541}
542
543pub fn get_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
544    match format {
545        OutputFormat::Human => Box::new(HumanFormatter::new()),
546        OutputFormat::Json => Box::new(JsonFormatter::new()),
547        OutputFormat::Yaml => Box::new(YamlFormatter::new()),
548        OutputFormat::Markdown => Box::new(MarkdownFormatter::new()),
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    #[test]
557    fn test_output_format_from_str() {
558        assert_eq!(
559            "human".parse::<OutputFormat>().unwrap(),
560            OutputFormat::Human
561        );
562        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
563        assert_eq!("yaml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
564        assert_eq!("yml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
565        assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Human);
566        assert_eq!(
567            "pretty".parse::<OutputFormat>().unwrap(),
568            OutputFormat::Human
569        );
570        assert!("invalid".parse::<OutputFormat>().is_err());
571    }
572
573    #[test]
574    fn test_output_format_default() {
575        assert_eq!(OutputFormat::default(), OutputFormat::Human);
576    }
577
578    #[test]
579    fn test_get_formatter_returns_correct_type() {
580        // Just verify we can get formatters without panicking
581        let _ = get_formatter(OutputFormat::Human);
582        let _ = get_formatter(OutputFormat::Json);
583        let _ = get_formatter(OutputFormat::Yaml);
584    }
585
586    #[test]
587    fn test_yaml_formatter_basic() {
588        let formatter = YamlFormatter::new();
589        let status = crate::status::StatusResponse::new("example.com".to_string());
590        let output = formatter.format_status(&status);
591        assert!(output.contains("example.com"));
592        assert!(output.contains("domain"));
593    }
594
595    #[test]
596    fn test_format_as_yaml_primitives() {
597        assert_eq!(format_as_yaml(&serde_json::json!(null), 0), "null");
598        assert_eq!(format_as_yaml(&serde_json::json!(true), 0), "true");
599        assert_eq!(format_as_yaml(&serde_json::json!(42), 0), "42");
600        assert_eq!(format_as_yaml(&serde_json::json!("hello"), 0), "hello");
601    }
602
603    #[test]
604    fn test_format_as_yaml_array() {
605        let output = format_as_yaml(&serde_json::json!([1, 2, 3]), 0);
606        assert!(output.contains("- 1"));
607        assert!(output.contains("- 2"));
608        assert!(output.contains("- 3"));
609    }
610
611    #[test]
612    fn yaml_is_clean_block_style_without_blank_or_trailing_space_lines() {
613        use serde_json::json;
614        // A top-level array used to start with a blank line, and an object
615        // inside an array rendered as `- ` (trailing space) then its keys.
616        let doc = format_as_yaml(
617            &json!([
618                {"name": "a", "ttl": 300},
619                {"name": "b", "tags": ["x", "z"], "empty": [], "meta": {"k": "v"}},
620                [1, 2]
621            ]),
622            0,
623        );
624        let expected = "\
625- name: a
626  ttl: 300
627- empty: []
628  meta:
629    k: v
630  name: b
631  tags:
632    - x
633    - z
634- - 1
635  - 2";
636        assert_eq!(doc, expected);
637        for line in doc.lines() {
638            assert!(!line.ends_with(' '), "trailing space: {line:?}");
639        }
640
641        let doc = format_as_yaml(&json!({"domain": "x", "records": [{"a": 1}]}), 0);
642        assert_eq!(doc, "domain: x\nrecords:\n  - a: 1");
643    }
644
645    #[test]
646    fn sanitize_line_strips_controls_and_folds_line_breaks() {
647        // Bare C0/C1 controls (CR, backspace, BEL, C1 CSI 0x9B) could
648        // overwrite or spoof rendered lines; newline/tab/U+2028 would forge
649        // a row.
650        assert_eq!(sanitize_line("a\rb\x08c\x07d\u{009b}e"), "abcde");
651        assert_eq!(
652            sanitize_line("line1\nline2\tcol\u{2028}x"),
653            "line1 line2 col x"
654        );
655        // OSC terminated by ST (ESC \) and classic CSI are removed whole.
656        assert_eq!(
657            sanitize_line("\x1b]0;malicious title\x1b\\visible"),
658            "visible"
659        );
660        assert_eq!(sanitize_line("\x1b[31mred\x1b[0m"), "red");
661    }
662
663    #[test]
664    fn sanitize_line_drops_bidi_and_zero_width_format_chars() {
665        // A right-to-left override (Trojan Source) or zero-width space
666        // passed through, so remote text could read as something else.
667        let trojan = "a\u{202E}b\u{2066}c\u{200B}d\u{FEFF}e\u{061C}f\u{200E}g\u{2069}";
668        assert_eq!(sanitize_line(trojan), "abcdefg");
669        // Ordinary non-ASCII text is kept.
670        assert_eq!(sanitize_line("café — ü"), "café — ü");
671    }
672
673    #[test]
674    fn expiry_phrase_is_one_wording_for_past_and_future() {
675        assert_eq!(expiry_phrase(-5), "expired 5 days ago");
676        assert_eq!(expiry_phrase(-1), "expired 1 day ago");
677        assert_eq!(expiry_phrase(0), "expires in 0 days");
678        assert_eq!(expiry_phrase(1), "expires in 1 day");
679        assert_eq!(expiry_phrase(29), "expires in 29 days");
680        assert_eq!(expiry_emphasis(-1), Emphasis::Bad);
681        assert_eq!(expiry_emphasis(29), Emphasis::Bad);
682        assert_eq!(expiry_emphasis(30), Emphasis::Caution);
683        assert_eq!(expiry_emphasis(90), Emphasis::Good);
684    }
685
686    #[test]
687    fn availability_label_covers_every_verdict_and_tolerates_others() {
688        assert_eq!(availability_label("available").0, "AVAILABLE");
689        assert_eq!(availability_label("likely_available").0, "MAY BE AVAILABLE");
690        assert_eq!(availability_label("registered").0, "REGISTERED");
691        assert_eq!(
692            availability_label("likely_registered").0,
693            "LIKELY REGISTERED"
694        );
695        assert_eq!(availability_label("unknown"), ("UNKNOWN", Emphasis::Bad));
696        assert_eq!(availability_label("whatever"), ("UNKNOWN", Emphasis::Bad));
697    }
698
699    #[test]
700    fn propagation_detail_names_a_lone_responder() {
701        use crate::dns::{
702            ConsensusValue, DnsRecord, DnsServer, DnsStatus, PropagationServerResult, RecordData,
703            RecordType,
704        };
705        let answered = PropagationServerResult {
706            server: DnsServer::new("Google", "8.8.8.8", "NA", "Google"),
707            records: vec![DnsRecord {
708                name: "example.com".into(),
709                record_type: RecordType::A,
710                ttl: 300,
711                data: RecordData::A {
712                    address: "1.2.3.4".into(),
713                },
714            }],
715            response_time_ms: 12,
716            success: true,
717            error: None,
718            status: Some(DnsStatus::NoError),
719        };
720        let result = PropagationResult {
721            domain: "example.com".into(),
722            record_type: RecordType::A,
723            servers_checked: 2,
724            servers_responding: 1,
725            propagation_percentage: 100.0,
726            results: vec![answered],
727            consensus_values: vec![ConsensusValue::new(RecordType::A, "1.2.3.4")],
728            inconsistencies: vec![],
729            unreachable_servers: vec![],
730            dnssec_validated: false,
731            nameserver_details: None,
732        };
733        assert_eq!(
734            propagation_detail(&result),
735            "the one responding server answered"
736        );
737        // Markdown used to say "all 1 responding servers agree".
738        let md = MarkdownFormatter::new().format_propagation(&result);
739        assert!(md.contains("the one responding server answered"), "{md}");
740    }
741
742    #[test]
743    fn dnssec_depth_notes_what_each_tier_does_not_verify() {
744        assert_eq!(dnssec_depth(AuthenticationTier::Unsigned).1, None);
745        let digest = dnssec_depth(AuthenticationTier::DigestOnly).1.unwrap();
746        assert!(digest.contains("RRSIG signatures, validity periods"));
747    }
748
749    #[test]
750    fn test_format_as_yaml_empty_collections() {
751        assert_eq!(format_as_yaml(&serde_json::json!([]), 0), "[]");
752        assert_eq!(format_as_yaml(&serde_json::json!({}), 0), "{}");
753    }
754
755    // --- #54: YAML scalar quoting hardening ----------------------------
756
757    #[test]
758    fn yaml_scalar_quoting_hardened() {
759        use serde_json::json;
760        let y = |s: &str| format_as_yaml(&json!(s), 0);
761
762        // YAML 1.1 bool/null tokens must be quoted to stay strings.
763        assert_eq!(y("No"), "\"No\"");
764        assert_eq!(y("true"), "\"true\"");
765        assert_eq!(y("null"), "\"null\"");
766        assert_eq!(y("~"), "\"~\"");
767
768        // Empty + leading/trailing whitespace.
769        assert_eq!(y(""), "\"\"");
770        assert_eq!(y(" leading"), "\" leading\"");
771        assert_eq!(y("trailing "), "\"trailing \"");
772
773        // Leading YAML indicator characters.
774        for s in [
775            "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&amp", "|pipe", ">gt",
776            "%pct", "`tick", "?q", ",comma",
777        ] {
778            assert_eq!(
779                y(s),
780                format!("\"{s}\""),
781                "must quote leading-indicator {s:?}"
782            );
783        }
784
785        // Colon-space breaks a plain scalar.
786        assert_eq!(y("key: value"), "\"key: value\"");
787
788        // Control chars / ANSI are escaped, never emitted raw.
789        assert_eq!(y("a\tb"), "\"a\\tb\"");
790        assert_eq!(y("a\rb"), "\"a\\rb\"");
791        assert_eq!(y("x\x1b[31m"), "\"x\\x1b[31m\"");
792
793        // Safe plain scalars must NOT be over-quoted.
794        assert_eq!(y("plain-value"), "plain-value");
795        assert_eq!(y("has internal spaces"), "has internal spaces");
796        assert_eq!(y("ns1.example.com"), "ns1.example.com");
797    }
798
799    #[test]
800    fn yaml_quotes_strings_a_resolver_would_read_as_non_strings() {
801        use serde_json::json;
802        let y = |s: &str| format_as_yaml(&json!(s), 0);
803
804        for s in [
805            "+1.5555550100",        // WHOIS phone → float 1.55555501
806            "292",                  // registrar IANA id string → int
807            "0123",                 // YAML 1.1 octal → 83
808            "1:20",                 // YAML 1.1 sexagesimal → 80
809            "2024-01-15",           // date
810            "2024-01-15T04:00:00Z", // RFC 3339 timestamp → datetime
811            ".inf",
812            ".NaN",
813            "=",  // YAML 1.1 value key: PyYAML raises ConstructorError
814            "<<", // merge key: PyYAML raises ConstructorError
815            "y",  // YAML 1.1 single-letter bools, any case
816            "N",
817        ] {
818            assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
819        }
820
821        // Hostnames and words stay plain; real JSON numbers are never quoted.
822        assert_eq!(y("ns1.example.com"), "ns1.example.com");
823        assert_eq!(y("yes-but-longer"), "yes-but-longer");
824        assert_eq!(y("a=b"), "a=b");
825        assert_eq!(format_as_yaml(&json!(292), 0), "292");
826        assert_eq!(format_as_yaml(&json!(1.5), 0), "1.5");
827    }
828
829    #[test]
830    fn yaml_escapes_unicode_line_breaks() {
831        use serde_json::json;
832        // U+2028/U+2029 aren't `char::is_control`, so they used to be emitted
833        // raw — and a raw one (or U+0085) makes the whole document fail to
834        // parse. They must be quoted and escaped with YAML's \L / \P / \N, in
835        // values and in keys alike.
836        let y = |s: &str| format_as_yaml(&json!(s), 0);
837        assert_eq!(y("a\u{2028}b"), "\"a\\Lb\"");
838        assert_eq!(y("a\u{2029}b"), "\"a\\Pb\"");
839        assert_eq!(y("a\u{0085}b"), "\"a\\Nb\"");
840
841        let doc = format_as_yaml(&json!({ "k\u{2028}ey": "v" }), 0);
842        assert_eq!(doc, "\"k\\Ley\": v");
843    }
844
845    #[test]
846    fn yaml_object_keys_with_significant_chars_are_quoted() {
847        // Keys from attacker-controlled flattened maps (e.g. RDAP `extra`) must
848        // be quoted too, or a key like "a\ninjected: true" forges structure.
849        let doc = format_as_yaml(&serde_json::json!({ "a\nb": "v" }), 0);
850        assert!(
851            doc.contains("\"a\\nb\""),
852            "malicious key must be quoted/escaped: {doc:?}"
853        );
854        // Normal identifier keys stay unquoted.
855        let doc = format_as_yaml(&serde_json::json!({ "domain": "x" }), 0);
856        assert!(
857            doc.starts_with("domain:"),
858            "plain key must not be quoted: {doc:?}"
859        );
860    }
861}