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 passes every value through
4//! [`sanitize_line`] against terminal escape injection and forged rows;
5//! bespoke layouts call `sanitize_line` by hand. Colors can be disabled
6//! (`without_colors`).
7
8use chrono::{DateTime, Utc};
9use colored::ColoredString;
10
11use super::OutputFormatter;
12
13// Shared with the per-concern submodules below (each does `use super::*`).
14pub(super) use super::contact::{self, Contact, FlatContacts};
15pub(super) use super::grouping::render_grouped;
16pub(super) use super::{
17    day_count, days_until, dnssec_depth, expiry_emphasis, expiry_phrase, format_duration,
18    propagation_detail, propagation_difference, sanitize_line, Emphasis, DNSSEC_NOTE, GEO_NOTE,
19};
20pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
21pub(super) use crate::colors::CatppuccinExt;
22pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
23pub(super) use crate::lookup::LookupResult;
24pub(super) use crate::rdap::RdapResponse;
25pub(super) use crate::status::StatusResponse;
26pub(super) use crate::whois::WhoisResponse;
27pub(super) use colored::Colorize;
28
29mod delegation;
30mod diff;
31mod dig;
32mod dns;
33mod domain_info;
34mod lookup;
35mod propagation;
36mod rdap;
37mod security;
38mod status;
39mod whois;
40
41pub struct HumanFormatter {
42    use_colors: bool,
43}
44
45impl Default for HumanFormatter {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl HumanFormatter {
52    pub fn new() -> Self {
53        Self { use_colors: true }
54    }
55
56    pub fn without_colors(mut self) -> Self {
57        self.use_colors = false;
58        self
59    }
60
61    /// Applies `style` to `text`, or returns it plain when colors are off.
62    fn paint(&self, text: &str, style: impl FnOnce(&str) -> ColoredString) -> String {
63        if self.use_colors {
64            style(text).to_string()
65        } else {
66            text.to_string()
67        }
68    }
69
70    fn label(&self, text: &str) -> String {
71        self.paint(text, |t| t.sky().bold())
72    }
73
74    fn value(&self, text: &str) -> String {
75        self.paint(text, |t| t.ctp_white())
76    }
77
78    fn success(&self, text: &str) -> String {
79        self.paint(text, |t| t.ctp_green().bold())
80    }
81
82    fn warning(&self, text: &str) -> String {
83        self.paint(text, |t| t.ctp_yellow().bold())
84    }
85
86    fn error(&self, text: &str) -> String {
87        self.paint(text, |t| t.ctp_red().bold())
88    }
89
90    fn dim(&self, text: &str) -> String {
91        self.paint(text, |t| t.overlay1())
92    }
93
94    /// Styles `text` by an [`Emphasis`]: green, plain value, yellow, red.
95    fn emphasize(&self, text: &str, emphasis: Emphasis) -> String {
96        match emphasis {
97            Emphasis::Good => self.success(text),
98            Emphasis::Neutral => self.value(text),
99            Emphasis::Caution => self.warning(text),
100            Emphasis::Bad => self.error(text),
101        }
102    }
103
104    /// A [`Rows`] writer appending `label: value` lines to `out` at `indent`.
105    fn rows<'a>(&'a self, out: &'a mut Vec<String>, indent: &str) -> Rows<'a> {
106        Rows {
107            f: self,
108            out,
109            indent: indent.to_string(),
110        }
111    }
112
113    fn header(&self, text: &str) -> String {
114        // Underline width is the character count, not the byte length: a header
115        // containing an IDN / non-ASCII domain would otherwise be over-ruled by
116        // one or more extra dashes per multi-byte char. (Matches the diff
117        // formatter's chars().count() convention.)
118        let width = text.chars().count();
119        if self.use_colors {
120            format!(
121                "\n{}\n{}",
122                text.lavender().bold(),
123                "─".repeat(width).subtext0()
124            )
125        } else {
126            format!("\n{}\n{}", text, "-".repeat(width))
127        }
128    }
129
130    /// Renders the CAA policy block (records, issuer match, note) shared
131    /// between `format_status` and `format_ssl`. `indent` is the leading
132    /// whitespace per line — typically `"  "`.
133    fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
134        let mut out = Vec::new();
135        let mut block = self.rows(&mut out, indent);
136        let mut rows = block.section("CAA Policy");
137
138        if !caa.has_policy {
139            let line = format!(
140                "{}{}",
141                rows.indent,
142                self.value("No CAA records (any CA may issue)")
143            );
144            rows.push(line);
145        } else {
146            rows.opt("Found at", &caa.effective_domain);
147            for r in &caa.records {
148                let line = format!(
149                    "{}{} {} \"{}\"",
150                    rows.indent,
151                    self.value(&r.flags.to_string()),
152                    self.label(&sanitize_line(&r.tag)),
153                    sanitize_line(&r.value)
154                );
155                rows.push(line);
156            }
157        }
158
159        if let Some(m) = caa.issuer_match {
160            let rendered = match m {
161                IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
162                IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
163                IssuerCaaMatch::Mismatch => self
164                    .warning("issuer not in current CAA policy (informational — see note below)"),
165                IssuerCaaMatch::Indeterminate => {
166                    self.warning("CAA present but no issue/issuewild tags")
167                }
168            };
169            rows.kv("Issuer vs CAA", rendered);
170        }
171
172        // Note is appended separately by the caller so it can sit at the
173        // very bottom of the overall output, un-indented.
174        out
175    }
176
177    /// Appends the trailing CAA note as the very last lines of an output
178    /// buffer: a blank separator line followed by `note: …` with no
179    /// indentation, so the explanation reads as a footer to the whole
180    /// report rather than part of the CAA block.
181    fn push_caa_note_footer(&self, out: &mut Vec<String>, caa: &CaaPolicy) {
182        out.push(String::new());
183        out.push(format!("note: {}", caa.note));
184    }
185
186    /// `<date> (expires in N days)` / `<date> (expired N days ago)`, colored
187    /// by urgency ([`expiry_emphasis`]).
188    fn format_expiry_status(&self, date: DateTime<Utc>, days_until: i64) -> String {
189        let text = format!(
190            "{} ({})",
191            date.format("%Y-%m-%d"),
192            expiry_phrase(days_until)
193        );
194        self.emphasize(&text, expiry_emphasis(days_until))
195    }
196
197    /// The bare expiry phrase for `days_until`, colored by urgency.
198    fn expiry_countdown(&self, days_until: i64) -> String {
199        self.emphasize(&expiry_phrase(days_until), expiry_emphasis(days_until))
200    }
201}
202
203/// Appends `label: value` rows at one indent. Remote text goes through
204/// [`sanitize_line`] here, so no row can skip the terminal-injection guard or
205/// break onto a forged line of its own.
206struct Rows<'a> {
207    f: &'a HumanFormatter,
208    out: &'a mut Vec<String>,
209    indent: String,
210}
211
212impl Rows<'_> {
213    /// Appends a pre-built line as-is.
214    fn push(&mut self, line: String) {
215        self.out.push(line);
216    }
217
218    /// Appends pre-built lines as-is.
219    fn extend(&mut self, lines: Vec<String>) {
220        self.out.extend(lines);
221    }
222
223    /// Appends an empty separator line.
224    fn blank(&mut self) {
225        self.out.push(String::new());
226    }
227
228    /// `label: <styled>` for a value the caller already styled (and, if it is
229    /// remote text, sanitized).
230    fn kv(&mut self, label: &str, styled: String) {
231        let label = self.f.label(label);
232        self.out.push(format!("{}{label}: {styled}", self.indent));
233    }
234
235    /// `label: value` for remote text: sanitized, then value-styled.
236    fn text(&mut self, label: &str, text: &str) {
237        let value = self.f.value(&sanitize_line(text));
238        self.kv(label, value);
239    }
240
241    /// [`Self::text`], skipped when the field is absent.
242    fn opt(&mut self, label: &str, text: &Option<String>) {
243        if let Some(text) = text {
244            self.text(label, text);
245        }
246    }
247
248    /// `label: YYYY-MM-DD`, skipped when absent.
249    fn date(&mut self, label: &str, date: Option<DateTime<Utc>>) {
250        if let Some(date) = date {
251            let value = self.f.value(&date.format("%Y-%m-%d").to_string());
252            self.kv(label, value);
253        }
254    }
255
256    /// `Expires: <date> (expires in N days)`, colored by urgency (see
257    /// [`HumanFormatter::format_expiry_status`]); skipped when absent.
258    fn expires(&mut self, date: Option<DateTime<Utc>>) {
259        if let Some(date) = date {
260            let status = self.f.format_expiry_status(date, days_until(date));
261            self.kv("Expires", status);
262        }
263    }
264
265    /// `label:` then one `- item` row per entry, one level deeper; nothing
266    /// for an empty list.
267    fn list(&mut self, label: &str, items: &[String]) {
268        if items.is_empty() {
269            return;
270        }
271        let label = self.f.label(label);
272        self.out.push(format!("{}{label}:", self.indent));
273        for item in items {
274            let item = self.f.value(&sanitize_line(item));
275            self.out.push(format!("{}  - {item}", self.indent));
276        }
277    }
278
279    /// A writer into the same output at another indent.
280    fn at(&mut self, indent: &str) -> Rows<'_> {
281        self.f.rows(self.out, indent)
282    }
283
284    /// A blank-line-led `label:` heading; returns the writer for its rows,
285    /// one level deeper.
286    fn section(&mut self, label: &str) -> Rows<'_> {
287        let heading = self.f.label(label);
288        self.out.push(format!("\n{}{heading}:", self.indent));
289        let indent = format!("{}  ", self.indent);
290        self.at(&indent)
291    }
292
293    /// One contact block: a `<role> Contact` section with a row per populated
294    /// field. An empty contact renders nothing, never a bare heading.
295    fn contact(&mut self, role: &str, contact: Contact<'_>) {
296        if contact.is_empty() {
297            return;
298        }
299        let mut rows = self.section(&format!("{role} Contact"));
300        for (label, field) in contact.fields() {
301            rows.opt(label, field);
302        }
303    }
304
305    /// [`Self::contact`] for each of [`contact::ROLES`].
306    fn contacts(&mut self, contacts: [Contact<'_>; 3]) {
307        for (role, c) in contact::ROLES.into_iter().zip(contacts) {
308            self.contact(role, c);
309        }
310    }
311}
312
313with_report_methods!(impl_forwarding!(HumanFormatter;));
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn formatter() -> HumanFormatter {
320        HumanFormatter::new().without_colors()
321    }
322
323    fn date(s: &str) -> DateTime<Utc> {
324        s.parse().unwrap()
325    }
326
327    #[test]
328    fn expiry_status_says_expired_days_ago_never_a_negative_count() {
329        let out = formatter().format_expiry_status(date("2024-01-01T00:00:00Z"), -3);
330        assert_eq!(out, "2024-01-01 (expired 3 days ago)");
331        let out = formatter().format_expiry_status(date("2024-01-01T00:00:00Z"), -1);
332        assert_eq!(out, "2024-01-01 (expired 1 day ago)");
333    }
334
335    #[test]
336    fn expiry_status_counts_down_in_every_urgency_band() {
337        for days in [0, 15, 30, 60, 300] {
338            let out = formatter().format_expiry_status(date("2027-01-01T00:00:00Z"), days);
339            let expected = if days == 1 {
340                "1 day".into()
341            } else {
342                format!("{days} days")
343            };
344            assert_eq!(out, format!("2027-01-01 (expires in {expected})"));
345        }
346    }
347
348    #[test]
349    fn expiry_status_is_colored_by_urgency() {
350        colored::control::set_override(true);
351        let f = HumanFormatter::new();
352        let when = date("2027-01-01T00:00:00Z");
353        let (red, yellow, green) = ("\x1b[1;91m", "\x1b[1;93m", "\x1b[1;92m");
354        assert!(f.format_expiry_status(when, -3).starts_with(red));
355        assert!(f.format_expiry_status(when, 29).starts_with(red));
356        assert!(f.format_expiry_status(when, 30).starts_with(yellow));
357        assert!(f.format_expiry_status(when, 90).starts_with(green));
358        colored::control::unset_override();
359    }
360
361    #[test]
362    fn rows_fold_remote_newlines_so_a_value_cannot_forge_a_row() {
363        // A registrar carrying "\n  Expires: 2099..." used to print as a
364        // second, legitimate-looking row under the real ones.
365        let mut whois = WhoisResponse::parse("example.com", "whois.test", "Registrar: R\n");
366        whois.registrar =
367            Some("Evil Registrar\n  Expires: 2099-01-01 (expires in 9999 days)".into());
368        whois.nameservers = vec!["ns1.example\n  Status: ok".into()];
369        let out = formatter().format_whois(&whois);
370        assert!(
371            out.contains("  Registrar: Evil Registrar   Expires: 2099-01-01"),
372            "got:\n{out}"
373        );
374        assert!(
375            !out.lines()
376                .any(|l| l.trim_start().starts_with("Expires: 2099")),
377            "forged row:\n{out}"
378        );
379        assert!(
380            !out.lines()
381                .any(|l| l.trim_start().starts_with("Status: ok")),
382            "forged list row:\n{out}"
383        );
384    }
385
386    #[test]
387    fn domain_info_renders_registrar_detail_and_lifecycle_fields() {
388        // The PR #101 registrar-detail + derived-lifecycle fields were
389        // computed and serialized but never rendered by the human formatter,
390        // so `seer info` (default format) silently hid a documented feature
391        // (2026-07-11 review).
392        let whois = WhoisResponse::parse(
393            "example.com",
394            "whois.test",
395            "Registrar: Example Registrar\n\
396             Creation Date: 2020-01-01T00:00:00Z\n\
397             Registry Expiry Date: 2099-01-01T00:00:00Z\n\
398             Domain Status: clientTransferProhibited\n",
399        );
400        let mut info =
401            crate::domain_info::DomainInfo::from_sources("example.com", None, Some(&whois));
402        info.registrar_abuse_email = Some("abuse@registrar.test".to_string());
403        info.registrar_abuse_phone = Some("+1.5555550100".to_string());
404        info.registrar_iana_id = Some("9999".to_string());
405        info.registrar_url = Some("https://registrar.test".to_string());
406
407        assert!(info.days_until_expiration.is_some(), "lifecycle computed");
408        assert!(!info.status_descriptions.is_empty(), "status decoded");
409
410        let out = formatter().format_domain_info(&info);
411        for needle in [
412            "Registrar Detail",
413            "abuse@registrar.test",
414            "+1.5555550100",
415            "9999",
416            "https://registrar.test",
417            "Days Until Expiry",
418            "Domain Age",
419            "Expiry Status",
420            "clientTransferProhibited:",
421        ] {
422            assert!(out.contains(needle), "missing {needle:?} in:\n{out}");
423        }
424    }
425
426    #[test]
427    fn domain_info_sanitizes_nameserver_and_status_fields() {
428        // Nameserver/status values come from attacker-controlled WHOIS parsing
429        // and were printed without sanitize_line (unlike the adjacent DNSSEC
430        // field), so an injected ANSI/OSC sequence reached the terminal.
431        let whois = WhoisResponse::parse(
432            "evil.example",
433            "whois.test",
434            "Name Server: ns1.evil\x1b[31mhidden\n\
435             Domain Status: ok\x1b]0;pwn\x07\n\
436             Registrar: Test Registrar\n\
437             Creation Date: 2020-01-01T00:00:00Z\n",
438        );
439        let info = crate::domain_info::DomainInfo::from_sources("evil.example", None, Some(&whois));
440        let out = formatter().format_domain_info(&info);
441        assert!(
442            !out.contains('\x1b'),
443            "ESC must not reach terminal: {out:?}"
444        );
445        assert!(
446            !out.contains('\x07'),
447            "BEL must not reach terminal: {out:?}"
448        );
449    }
450}