Skip to main content

seer_core/output/human/
mod.rs

1//! Colored terminal output (`--format human`). One inherent `format_*` method
2//! per report type, split into per-concern submodules; label/value rows go
3//! through the private `Rows` writer, which sanitizes every value against
4//! terminal escape injection. Colors can be disabled (`without_colors`).
5
6use chrono::{DateTime, TimeDelta, Utc};
7use colored::ColoredString;
8
9use super::OutputFormatter;
10
11// Shared with the per-concern submodules below (each does `use super::*`).
12pub(super) use super::contact::{self, Contact, FlatContacts};
13pub(super) use super::days_until;
14pub(super) use super::grouping::render_grouped;
15pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
16pub(super) use crate::colors::CatppuccinExt;
17pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
18pub(super) use crate::lookup::LookupResult;
19pub(super) use crate::rdap::{ContactInfo, RdapResponse};
20pub(super) use crate::status::StatusResponse;
21pub(super) use crate::whois::WhoisResponse;
22pub(super) use colored::Colorize;
23
24mod delegation;
25mod diff;
26mod dns;
27mod domain_info;
28mod lookup;
29mod propagation;
30mod rdap;
31mod security;
32mod status;
33mod whois;
34
35static_regex! {
36    /// Strips ANSI escape sequences from untrusted external strings to prevent
37    /// terminal injection via malicious WHOIS/RDAP response data. The OSC branch
38    /// accepts both BEL (`\x07`) and ST (`\x1b\\`) terminators (and excludes ESC
39    /// from the payload run so it can't over-consume across sequences).
40    ANSI_ESCAPE_RE = r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[A-Z@-_]";
41}
42
43/// Sanitizes untrusted external text (WHOIS/RDAP field values) for safe display
44/// on a terminal. First removes well-formed ANSI escape sequences, then drops
45/// any remaining C0/C1 control characters (bare CR, backspace, BEL, stray ESC,
46/// the C1 range, DEL) that could overwrite or spoof rendered lines — keeping
47/// only `\n` and `\t`, which are legitimate layout. Mirrors the markdown
48/// `MdSafe` guard so the human path is no longer the weaker one (issue #53).
49pub(super) fn sanitize_display(s: &str) -> String {
50    ANSI_ESCAPE_RE
51        .replace_all(s, "")
52        .chars()
53        .filter(|&c| c == '\n' || c == '\t' || !c.is_control())
54        .collect()
55}
56
57/// Formats a [`TimeDelta`] as a compact human duration (`Ns` / `Nm Ns` /
58/// `Nh Nm`). Shared across the human and markdown formatters — `pub(crate)` so
59/// the markdown follow formatter can reuse it instead of duplicating the
60/// breakdown.
61pub(crate) fn format_duration(duration: TimeDelta) -> String {
62    let total_secs = duration.num_seconds();
63    if total_secs < 60 {
64        format!("{}s", total_secs)
65    } else if total_secs < 3600 {
66        let mins = total_secs / 60;
67        let secs = total_secs % 60;
68        format!("{}m {}s", mins, secs)
69    } else {
70        let hours = total_secs / 3600;
71        let mins = (total_secs % 3600) / 60;
72        format!("{}h {}m", hours, mins)
73    }
74}
75
76/// [`contact::rdap_views`] as this formatter renders them: the registrant
77/// block drops name/organization, which print as the top-level
78/// Registrant/Organization lines, so an identity-only registrant opens no
79/// empty heading.
80pub(super) fn detail_views(contacts: &[Option<ContactInfo>; 3]) -> [Contact<'_>; 3] {
81    let [registrant, admin, tech] = contact::rdap_views(contacts);
82    [registrant.without_identity(), admin, tech]
83}
84
85pub struct HumanFormatter {
86    use_colors: bool,
87}
88
89impl Default for HumanFormatter {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95impl HumanFormatter {
96    pub fn new() -> Self {
97        Self { use_colors: true }
98    }
99
100    pub fn without_colors(mut self) -> Self {
101        self.use_colors = false;
102        self
103    }
104
105    /// Applies `style` to `text`, or returns it plain when colors are off.
106    fn paint(&self, text: &str, style: impl FnOnce(&str) -> ColoredString) -> String {
107        if self.use_colors {
108            style(text).to_string()
109        } else {
110            text.to_string()
111        }
112    }
113
114    fn label(&self, text: &str) -> String {
115        self.paint(text, |t| t.sky().bold())
116    }
117
118    fn value(&self, text: &str) -> String {
119        self.paint(text, |t| t.ctp_white())
120    }
121
122    fn success(&self, text: &str) -> String {
123        self.paint(text, |t| t.ctp_green().bold())
124    }
125
126    fn warning(&self, text: &str) -> String {
127        self.paint(text, |t| t.ctp_yellow().bold())
128    }
129
130    fn error(&self, text: &str) -> String {
131        self.paint(text, |t| t.ctp_red().bold())
132    }
133
134    fn dim(&self, text: &str) -> String {
135        self.paint(text, |t| t.overlay1())
136    }
137
138    /// A [`Rows`] writer appending `label: value` lines to `out` at `indent`.
139    fn rows<'a>(&'a self, out: &'a mut Vec<String>, indent: &str) -> Rows<'a> {
140        Rows {
141            f: self,
142            out,
143            indent: indent.to_string(),
144        }
145    }
146
147    fn header(&self, text: &str) -> String {
148        // Underline width is the character count, not the byte length: a header
149        // containing an IDN / non-ASCII domain would otherwise be over-ruled by
150        // one or more extra dashes per multi-byte char. (Matches the diff
151        // formatter's chars().count() convention.)
152        let width = text.chars().count();
153        if self.use_colors {
154            format!(
155                "\n{}\n{}",
156                text.lavender().bold(),
157                "─".repeat(width).subtext0()
158            )
159        } else {
160            format!("\n{}\n{}", text, "-".repeat(width))
161        }
162    }
163
164    /// Renders the CAA policy block (records, issuer match, note) shared
165    /// between `format_status` and `format_ssl`. `indent` is the leading
166    /// whitespace per line — typically `"  "`.
167    fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
168        let mut out = Vec::new();
169        let mut block = self.rows(&mut out, indent);
170        let mut rows = block.section("CAA Policy");
171
172        if !caa.has_policy {
173            let line = format!(
174                "{}{}",
175                rows.indent,
176                self.value("No CAA records (any CA may issue)")
177            );
178            rows.push(line);
179        } else {
180            rows.opt("Found at", &caa.effective_domain);
181            for r in &caa.records {
182                let line = format!(
183                    "{}{} {} \"{}\"",
184                    rows.indent,
185                    self.value(&r.flags.to_string()),
186                    self.label(&r.tag),
187                    sanitize_display(&r.value)
188                );
189                rows.push(line);
190            }
191        }
192
193        if let Some(m) = caa.issuer_match {
194            let rendered = match m {
195                IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
196                IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
197                IssuerCaaMatch::Mismatch => self
198                    .warning("issuer not in current CAA policy (informational — see note below)"),
199                IssuerCaaMatch::Indeterminate => {
200                    self.warning("CAA present but no issue/issuewild tags")
201                }
202            };
203            rows.kv("Issuer vs CAA", rendered);
204        }
205
206        // Note is appended separately by the caller so it can sit at the
207        // very bottom of the overall output, un-indented.
208        out
209    }
210
211    /// Appends the trailing CAA note as the very last lines of an output
212    /// buffer: a blank separator line followed by `note: …` with no
213    /// indentation, so the explanation reads as a footer to the whole
214    /// report rather than part of the CAA block.
215    fn push_caa_note_footer(&self, out: &mut Vec<String>, caa: &CaaPolicy) {
216        out.push(String::new());
217        out.push(format!("note: {}", caa.note));
218    }
219
220    /// Formats an expiration date with a human-readable status suffix.
221    ///
222    /// Behaviour:
223    /// - already expired (negative days): red "expired N days ago"
224    /// - <30 days remaining: red "expires in N days!"
225    /// - <90 days remaining: yellow "expires in N days"
226    /// - otherwise: green "expires in N days"
227    fn format_expiry_status(&self, expiry_str: &str, days_until: i64) -> String {
228        if days_until < 0 {
229            self.error(&format!(
230                "{} (expired {} days ago)",
231                expiry_str, -days_until
232            ))
233        } else if days_until < 30 {
234            self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
235        } else if days_until < 90 {
236            self.warning(&format!("{} (expires in {} days)", expiry_str, days_until))
237        } else {
238            self.success(&format!("{} (expires in {} days)", expiry_str, days_until))
239        }
240    }
241}
242
243/// Appends `label: value` rows at one indent. Remote text goes through
244/// [`sanitize_display`] here, so no row can skip the terminal-injection guard.
245struct Rows<'a> {
246    f: &'a HumanFormatter,
247    out: &'a mut Vec<String>,
248    indent: String,
249}
250
251impl Rows<'_> {
252    /// Appends a pre-built line as-is.
253    fn push(&mut self, line: String) {
254        self.out.push(line);
255    }
256
257    /// Appends pre-built lines as-is.
258    fn extend(&mut self, lines: Vec<String>) {
259        self.out.extend(lines);
260    }
261
262    /// Appends an empty separator line.
263    fn blank(&mut self) {
264        self.out.push(String::new());
265    }
266
267    /// `label: <styled>` for a value the caller already styled (and, if it is
268    /// remote text, sanitized).
269    fn kv(&mut self, label: &str, styled: String) {
270        let label = self.f.label(label);
271        self.out.push(format!("{}{label}: {styled}", self.indent));
272    }
273
274    /// `label: value` for remote text: sanitized, then value-styled.
275    fn text(&mut self, label: &str, text: &str) {
276        let value = self.f.value(&sanitize_display(text));
277        self.kv(label, value);
278    }
279
280    /// [`Self::text`], skipped when the field is absent.
281    fn opt(&mut self, label: &str, text: &Option<String>) {
282        if let Some(text) = text {
283            self.text(label, text);
284        }
285    }
286
287    /// `label: YYYY-MM-DD`, skipped when absent.
288    fn date(&mut self, label: &str, date: Option<DateTime<Utc>>) {
289        if let Some(date) = date {
290            let value = self.f.value(&date.format("%Y-%m-%d").to_string());
291            self.kv(label, value);
292        }
293    }
294
295    /// `Expires: <date> (expires in N days)`, colored by urgency (see
296    /// [`HumanFormatter::format_expiry_status`]); skipped when absent.
297    fn expires(&mut self, date: Option<DateTime<Utc>>) {
298        if let Some(date) = date {
299            let status = self
300                .f
301                .format_expiry_status(&date.format("%Y-%m-%d").to_string(), days_until(date));
302            self.kv("Expires", status);
303        }
304    }
305
306    /// `label:` then one `- item` row per entry, one level deeper; nothing
307    /// for an empty list.
308    fn list(&mut self, label: &str, items: &[String]) {
309        if items.is_empty() {
310            return;
311        }
312        let label = self.f.label(label);
313        self.out.push(format!("{}{label}:", self.indent));
314        for item in items {
315            let item = self.f.value(&sanitize_display(item));
316            self.out.push(format!("{}  - {item}", self.indent));
317        }
318    }
319
320    /// A writer into the same output at another indent.
321    fn at(&mut self, indent: &str) -> Rows<'_> {
322        self.f.rows(self.out, indent)
323    }
324
325    /// A blank-line-led `label:` heading; returns the writer for its rows,
326    /// one level deeper.
327    fn section(&mut self, label: &str) -> Rows<'_> {
328        let heading = self.f.label(label);
329        self.out.push(format!("\n{}{heading}:", self.indent));
330        let indent = format!("{}  ", self.indent);
331        self.at(&indent)
332    }
333
334    /// One contact block: a `<role> Contact` section with a row per populated
335    /// field. An empty contact renders nothing, never a bare heading.
336    fn contact(&mut self, role: &str, contact: Contact<'_>) {
337        if contact.is_empty() {
338            return;
339        }
340        let mut rows = self.section(&format!("{role} Contact"));
341        for (label, field) in contact.fields() {
342            rows.opt(label, field);
343        }
344    }
345
346    /// [`Self::contact`] for each of [`contact::ROLES`].
347    fn contacts(&mut self, contacts: [Contact<'_>; 3]) {
348        for (role, c) in contact::ROLES.into_iter().zip(contacts) {
349            self.contact(role, c);
350        }
351    }
352}
353
354with_report_methods!(impl_forwarding!(HumanFormatter;));
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn formatter() -> HumanFormatter {
361        HumanFormatter::new().without_colors()
362    }
363
364    #[test]
365    fn expired_shows_days_ago() {
366        let f = formatter();
367        let out = f.format_expiry_status("2024-01-01", -3);
368        assert!(out.contains("expired 3 days ago"), "got: {}", out);
369        assert!(!out.contains("-3"), "got: {}", out);
370    }
371
372    #[test]
373    fn expiring_soon_shows_expires_in() {
374        let f = formatter();
375        let out = f.format_expiry_status("2026-05-01", 15);
376        assert!(out.contains("expires in 15 days"), "got: {}", out);
377        assert!(!out.contains("days ago"), "got: {}", out);
378    }
379
380    #[test]
381    fn warning_window_uses_expires_in() {
382        let f = formatter();
383        let out = f.format_expiry_status("2026-07-01", 60);
384        assert!(out.contains("expires in 60 days"), "got: {}", out);
385        assert!(!out.contains("!"), "got: {}", out);
386    }
387
388    #[test]
389    fn healthy_expiry_uses_expires_in() {
390        let f = formatter();
391        let out = f.format_expiry_status("2027-01-01", 300);
392        assert!(out.contains("expires in 300 days"), "got: {}", out);
393        assert!(!out.contains("!"), "got: {}", out);
394    }
395
396    #[test]
397    fn expired_one_day_is_pluralized_simply() {
398        // We don't singularize; verify the raw format.
399        let f = formatter();
400        let out = f.format_expiry_status("2024-01-01", -1);
401        assert!(out.contains("expired 1 days ago"), "got: {}", out);
402    }
403
404    #[test]
405    fn boundary_30_days_is_warning_not_error() {
406        let f = formatter();
407        // 30 days -> not <30, so warning branch, no "!"
408        let out = f.format_expiry_status("2026-05-15", 30);
409        assert!(out.contains("expires in 30 days"), "got: {}", out);
410        assert!(!out.contains("!"), "got: {}", out);
411    }
412
413    // --- #53: terminal-escape / control-char sanitization ---------------
414
415    #[test]
416    fn sanitize_display_strips_bare_control_chars_keeps_newline_tab() {
417        // The old regex only removed ESC-introduced sequences; bare C0/C1
418        // control chars (CR, backspace, BEL, C1 CSI 0x9B) survived and could
419        // overwrite or spoof rendered lines. They must be stripped; newline
420        // and tab are legitimate layout and must be preserved.
421        let evil = "a\rb\x08c\x07d\u{009b}e";
422        let clean = sanitize_display(evil);
423        for bad in ['\r', '\x08', '\x07', '\u{009b}', '\u{001b}'] {
424            assert!(
425                !clean.contains(bad),
426                "{bad:?} must be stripped from {clean:?}"
427            );
428        }
429        assert!(
430            clean.contains('a') && clean.contains('e'),
431            "text kept: {clean:?}"
432        );
433        assert_eq!(
434            sanitize_display("line1\nline2\tcol"),
435            "line1\nline2\tcol",
436            "newline and tab must be preserved"
437        );
438    }
439
440    #[test]
441    fn sanitize_display_strips_osc_with_st_terminator() {
442        // OSC terminated by ST (ESC \\) rather than BEL previously slipped
443        // through, leaking the payload as visible text.
444        let clean = sanitize_display("\x1b]0;malicious title\x1b\\visible");
445        assert_eq!(
446            clean, "visible",
447            "OSC+ST sequence must be fully removed: {clean:?}"
448        );
449        assert!(!clean.contains('\x1b'));
450        // Regression: classic CSI color codes still stripped.
451        assert_eq!(sanitize_display("\x1b[31mred\x1b[0m"), "red");
452    }
453
454    #[test]
455    fn domain_info_renders_registrar_detail_and_lifecycle_fields() {
456        // The PR #101 registrar-detail + derived-lifecycle fields were
457        // computed and serialized but never rendered by the human formatter,
458        // so `seer info` (default format) silently hid a documented feature
459        // (2026-07-11 review).
460        let whois = WhoisResponse::parse(
461            "example.com",
462            "whois.test",
463            "Registrar: Example Registrar\n\
464             Creation Date: 2020-01-01T00:00:00Z\n\
465             Registry Expiry Date: 2099-01-01T00:00:00Z\n\
466             Domain Status: clientTransferProhibited\n",
467        );
468        let mut info =
469            crate::domain_info::DomainInfo::from_sources("example.com", None, Some(&whois));
470        info.registrar_abuse_email = Some("abuse@registrar.test".to_string());
471        info.registrar_abuse_phone = Some("+1.5555550100".to_string());
472        info.registrar_iana_id = Some("9999".to_string());
473        info.registrar_url = Some("https://registrar.test".to_string());
474
475        assert!(info.days_until_expiration.is_some(), "lifecycle computed");
476        assert!(!info.status_descriptions.is_empty(), "status decoded");
477
478        let out = formatter().format_domain_info(&info);
479        for needle in [
480            "Registrar Detail",
481            "abuse@registrar.test",
482            "+1.5555550100",
483            "9999",
484            "https://registrar.test",
485            "Days Until Expiry",
486            "Domain Age",
487            "Expiry Status",
488            "clientTransferProhibited:",
489        ] {
490            assert!(out.contains(needle), "missing {needle:?} in:\n{out}");
491        }
492    }
493
494    #[test]
495    fn domain_info_sanitizes_nameserver_and_status_fields() {
496        // Nameserver/status values come from attacker-controlled WHOIS parsing
497        // and were printed without sanitize_display (unlike the adjacent DNSSEC
498        // field), so an injected ANSI/OSC sequence reached the terminal.
499        let whois = WhoisResponse::parse(
500            "evil.example",
501            "whois.test",
502            "Name Server: ns1.evil\x1b[31mhidden\n\
503             Domain Status: ok\x1b]0;pwn\x07\n\
504             Registrar: Test Registrar\n\
505             Creation Date: 2020-01-01T00:00:00Z\n",
506        );
507        let info = crate::domain_info::DomainInfo::from_sources("evil.example", None, Some(&whois));
508        let out = formatter().format_domain_info(&info);
509        assert!(
510            !out.contains('\x1b'),
511            "ESC must not reach terminal: {out:?}"
512        );
513        assert!(
514            !out.contains('\x07'),
515            "BEL must not reach terminal: {out:?}"
516        );
517    }
518}