Skip to main content

seer_core/output/human/
mod.rs

1use chrono::TimeDelta;
2use once_cell::sync::Lazy;
3use regex::Regex;
4
5use super::OutputFormatter;
6
7// Shared with the per-concern submodules below (each does `use super::*`).
8pub(super) use super::grouping::render_grouped;
9pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
10pub(super) use crate::colors::CatppuccinExt;
11pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
12pub(super) use crate::lookup::LookupResult;
13pub(super) use crate::rdap::RdapResponse;
14pub(super) use crate::status::StatusResponse;
15pub(super) use crate::whois::WhoisResponse;
16pub(super) use colored::Colorize;
17
18mod diff;
19mod dns;
20mod domain_info;
21mod lookup;
22mod propagation;
23mod rdap;
24mod status;
25mod whois;
26
27/// Strips ANSI escape sequences from untrusted external strings to prevent
28/// terminal injection via malicious WHOIS/RDAP response data.
29static ANSI_ESCAPE_RE: Lazy<Regex> = Lazy::new(|| {
30    Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[A-Z@-_]")
31        .expect("Invalid ANSI escape regex")
32});
33
34pub(super) fn sanitize_display(s: &str) -> String {
35    ANSI_ESCAPE_RE.replace_all(s, "").to_string()
36}
37
38pub(super) fn format_duration(duration: TimeDelta) -> String {
39    let total_secs = duration.num_seconds();
40    if total_secs < 60 {
41        format!("{}s", total_secs)
42    } else if total_secs < 3600 {
43        let mins = total_secs / 60;
44        let secs = total_secs % 60;
45        format!("{}m {}s", mins, secs)
46    } else {
47        let hours = total_secs / 3600;
48        let mins = (total_secs % 3600) / 60;
49        format!("{}h {}m", hours, mins)
50    }
51}
52
53pub struct HumanFormatter {
54    use_colors: bool,
55}
56
57impl Default for HumanFormatter {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl HumanFormatter {
64    pub fn new() -> Self {
65        Self { use_colors: true }
66    }
67
68    pub fn without_colors(mut self) -> Self {
69        self.use_colors = false;
70        self
71    }
72
73    fn label(&self, text: &str) -> String {
74        if self.use_colors {
75            text.sky().bold().to_string()
76        } else {
77            text.to_string()
78        }
79    }
80
81    fn value(&self, text: &str) -> String {
82        if self.use_colors {
83            text.ctp_white().to_string()
84        } else {
85            text.to_string()
86        }
87    }
88
89    fn success(&self, text: &str) -> String {
90        if self.use_colors {
91            text.ctp_green().bold().to_string()
92        } else {
93            text.to_string()
94        }
95    }
96
97    fn warning(&self, text: &str) -> String {
98        if self.use_colors {
99            text.ctp_yellow().bold().to_string()
100        } else {
101            text.to_string()
102        }
103    }
104
105    fn error(&self, text: &str) -> String {
106        if self.use_colors {
107            text.ctp_red().bold().to_string()
108        } else {
109            text.to_string()
110        }
111    }
112
113    fn dim(&self, text: &str) -> String {
114        if self.use_colors {
115            text.overlay1().to_string()
116        } else {
117            text.to_string()
118        }
119    }
120
121    fn header(&self, text: &str) -> String {
122        // Underline width is the character count, not the byte length: a header
123        // containing an IDN / non-ASCII domain would otherwise be over-ruled by
124        // one or more extra dashes per multi-byte char. (Matches the diff
125        // formatter's chars().count() convention.)
126        let width = text.chars().count();
127        if self.use_colors {
128            format!(
129                "\n{}\n{}",
130                text.lavender().bold(),
131                "─".repeat(width).subtext0()
132            )
133        } else {
134            format!("\n{}\n{}", text, "-".repeat(width))
135        }
136    }
137
138    /// Renders the CAA policy block (records, issuer match, note) shared
139    /// between `format_status` and `format_ssl`. `indent` is the leading
140    /// whitespace per line — typically `"  "`.
141    fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
142        let mut out = Vec::new();
143        out.push(format!("\n{}{}:", indent, self.label("CAA Policy")));
144
145        if !caa.has_policy {
146            out.push(format!(
147                "{}  {}",
148                indent,
149                self.value("No CAA records (any CA may issue)")
150            ));
151        } else {
152            if let Some(ref eff) = caa.effective_domain {
153                out.push(format!(
154                    "{}  {}: {}",
155                    indent,
156                    self.label("Found at"),
157                    self.value(&sanitize_display(eff))
158                ));
159            }
160            for r in &caa.records {
161                out.push(format!(
162                    "{}  {} {} \"{}\"",
163                    indent,
164                    self.value(&r.flags.to_string()),
165                    self.label(&r.tag),
166                    sanitize_display(&r.value)
167                ));
168            }
169        }
170
171        if let Some(m) = caa.issuer_match {
172            let rendered = match m {
173                IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
174                IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
175                IssuerCaaMatch::Mismatch => self
176                    .warning("issuer not in current CAA policy (informational — see note below)"),
177                IssuerCaaMatch::Indeterminate => {
178                    self.warning("CAA present but no issue/issuewild tags")
179                }
180            };
181            out.push(format!(
182                "{}  {}: {}",
183                indent,
184                self.label("Issuer vs CAA"),
185                rendered
186            ));
187        }
188
189        // Note is appended separately by the caller so it can sit at the
190        // very bottom of the overall output, un-indented.
191        out
192    }
193
194    /// Appends the trailing CAA note as the very last lines of an output
195    /// buffer: a blank separator line followed by `note: …` with no
196    /// indentation, so the explanation reads as a footer to the whole
197    /// report rather than part of the CAA block.
198    fn push_caa_note_footer(&self, out: &mut Vec<String>, caa: &CaaPolicy) {
199        out.push(String::new());
200        out.push(format!("note: {}", caa.note));
201    }
202
203    /// Formats an expiration date with a human-readable status suffix.
204    ///
205    /// Behaviour:
206    /// - already expired (negative days): red "expired N days ago"
207    /// - <30 days remaining: red "expires in N days!"
208    /// - <90 days remaining: yellow "expires in N days"
209    /// - otherwise: green "expires in N days"
210    fn format_expiry_status(&self, expiry_str: &str, days_until: i64) -> String {
211        if days_until < 0 {
212            self.error(&format!(
213                "{} (expired {} days ago)",
214                expiry_str, -days_until
215            ))
216        } else if days_until < 30 {
217            self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
218        } else if days_until < 90 {
219            self.warning(&format!("{} (expires in {} days)", expiry_str, days_until))
220        } else {
221            self.success(&format!("{} (expires in {} days)", expiry_str, days_until))
222        }
223    }
224}
225
226// Thin dispatch layer: each trait method forwards to the inherent
227// method of the same name defined in the per-concern submodule. Rust
228// resolves the inherent method first, so this does not recurse.
229impl OutputFormatter for HumanFormatter {
230    fn format_whois(&self, response: &WhoisResponse) -> String {
231        self.format_whois(response)
232    }
233    fn format_rdap(&self, response: &RdapResponse) -> String {
234        self.format_rdap(response)
235    }
236    fn format_dns(&self, records: &[DnsRecord]) -> String {
237        self.format_dns(records)
238    }
239    fn format_propagation(&self, result: &PropagationResult) -> String {
240        self.format_propagation(result)
241    }
242    fn format_lookup(&self, result: &LookupResult) -> String {
243        self.format_lookup(result)
244    }
245    fn format_status(&self, response: &StatusResponse) -> String {
246        self.format_status(response)
247    }
248    fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
249        self.format_follow_iteration(iteration)
250    }
251    fn format_follow(&self, result: &FollowResult) -> String {
252        self.format_follow(result)
253    }
254    fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
255        self.format_availability(result)
256    }
257    fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
258        self.format_dnssec(report)
259    }
260    fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
261        self.format_tld(info)
262    }
263    fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
264        self.format_dns_comparison(comparison)
265    }
266    fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
267        self.format_subdomains(result)
268    }
269    fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
270        self.format_diff(diff)
271    }
272    fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
273        self.format_ssl(report)
274    }
275    fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
276        self.format_watch(report)
277    }
278    fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
279        self.format_domain_info(info)
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn formatter() -> HumanFormatter {
288        HumanFormatter::new().without_colors()
289    }
290
291    #[test]
292    fn expired_shows_days_ago() {
293        let f = formatter();
294        let out = f.format_expiry_status("2024-01-01", -3);
295        assert!(out.contains("expired 3 days ago"), "got: {}", out);
296        assert!(!out.contains("-3"), "got: {}", out);
297    }
298
299    #[test]
300    fn expiring_soon_shows_expires_in() {
301        let f = formatter();
302        let out = f.format_expiry_status("2026-05-01", 15);
303        assert!(out.contains("expires in 15 days"), "got: {}", out);
304        assert!(!out.contains("days ago"), "got: {}", out);
305    }
306
307    #[test]
308    fn warning_window_uses_expires_in() {
309        let f = formatter();
310        let out = f.format_expiry_status("2026-07-01", 60);
311        assert!(out.contains("expires in 60 days"), "got: {}", out);
312        assert!(!out.contains("!"), "got: {}", out);
313    }
314
315    #[test]
316    fn healthy_expiry_uses_expires_in() {
317        let f = formatter();
318        let out = f.format_expiry_status("2027-01-01", 300);
319        assert!(out.contains("expires in 300 days"), "got: {}", out);
320        assert!(!out.contains("!"), "got: {}", out);
321    }
322
323    #[test]
324    fn expired_one_day_is_pluralized_simply() {
325        // We don't singularize; verify the raw format.
326        let f = formatter();
327        let out = f.format_expiry_status("2024-01-01", -1);
328        assert!(out.contains("expired 1 days ago"), "got: {}", out);
329    }
330
331    #[test]
332    fn boundary_30_days_is_warning_not_error() {
333        let f = formatter();
334        // 30 days -> not <30, so warning branch, no "!"
335        let out = f.format_expiry_status("2026-05-15", 30);
336        assert!(out.contains("expires in 30 days"), "got: {}", out);
337        assert!(!out.contains("!"), "got: {}", out);
338    }
339}