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        if self.use_colors {
123            format!(
124                "\n{}\n{}",
125                text.lavender().bold(),
126                "─".repeat(text.len()).subtext0()
127            )
128        } else {
129            format!("\n{}\n{}", text, "-".repeat(text.len()))
130        }
131    }
132
133    /// Renders the CAA policy block (records, issuer match, note) shared
134    /// between `format_status` and `format_ssl`. `indent` is the leading
135    /// whitespace per line — typically `"  "`.
136    fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
137        let mut out = Vec::new();
138        out.push(format!("\n{}{}:", indent, self.label("CAA Policy")));
139
140        if !caa.has_policy {
141            out.push(format!(
142                "{}  {}",
143                indent,
144                self.value("No CAA records (any CA may issue)")
145            ));
146        } else {
147            if let Some(ref eff) = caa.effective_domain {
148                out.push(format!(
149                    "{}  {}: {}",
150                    indent,
151                    self.label("Found at"),
152                    self.value(&sanitize_display(eff))
153                ));
154            }
155            for r in &caa.records {
156                out.push(format!(
157                    "{}  {} {} \"{}\"",
158                    indent,
159                    self.value(&r.flags.to_string()),
160                    self.label(&r.tag),
161                    sanitize_display(&r.value)
162                ));
163            }
164        }
165
166        if let Some(m) = caa.issuer_match {
167            let rendered = match m {
168                IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
169                IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
170                IssuerCaaMatch::Mismatch => self
171                    .warning("issuer not in current CAA policy (informational — see note below)"),
172                IssuerCaaMatch::Indeterminate => {
173                    self.warning("CAA present but no issue/issuewild tags")
174                }
175            };
176            out.push(format!(
177                "{}  {}: {}",
178                indent,
179                self.label("Issuer vs CAA"),
180                rendered
181            ));
182        }
183
184        // Note is appended separately by the caller so it can sit at the
185        // very bottom of the overall output, un-indented.
186        out
187    }
188
189    /// Appends the trailing CAA note as the very last lines of an output
190    /// buffer: a blank separator line followed by `note: …` with no
191    /// indentation, so the explanation reads as a footer to the whole
192    /// report rather than part of the CAA block.
193    fn push_caa_note_footer(&self, out: &mut Vec<String>, caa: &CaaPolicy) {
194        out.push(String::new());
195        out.push(format!("note: {}", caa.note));
196    }
197
198    /// Formats an expiration date with a human-readable status suffix.
199    ///
200    /// Behaviour:
201    /// - already expired (negative days): red "expired N days ago"
202    /// - <30 days remaining: red "expires in N days!"
203    /// - <90 days remaining: yellow "expires in N days"
204    /// - otherwise: green "expires in N days"
205    fn format_expiry_status(&self, expiry_str: &str, days_until: i64) -> String {
206        if days_until < 0 {
207            self.error(&format!(
208                "{} (expired {} days ago)",
209                expiry_str, -days_until
210            ))
211        } else if days_until < 30 {
212            self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
213        } else if days_until < 90 {
214            self.warning(&format!("{} (expires in {} days)", expiry_str, days_until))
215        } else {
216            self.success(&format!("{} (expires in {} days)", expiry_str, days_until))
217        }
218    }
219}
220
221// Thin dispatch layer: each trait method forwards to the inherent
222// method of the same name defined in the per-concern submodule. Rust
223// resolves the inherent method first, so this does not recurse.
224impl OutputFormatter for HumanFormatter {
225    fn format_whois(&self, response: &WhoisResponse) -> String {
226        self.format_whois(response)
227    }
228    fn format_rdap(&self, response: &RdapResponse) -> String {
229        self.format_rdap(response)
230    }
231    fn format_dns(&self, records: &[DnsRecord]) -> String {
232        self.format_dns(records)
233    }
234    fn format_propagation(&self, result: &PropagationResult) -> String {
235        self.format_propagation(result)
236    }
237    fn format_lookup(&self, result: &LookupResult) -> String {
238        self.format_lookup(result)
239    }
240    fn format_status(&self, response: &StatusResponse) -> String {
241        self.format_status(response)
242    }
243    fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
244        self.format_follow_iteration(iteration)
245    }
246    fn format_follow(&self, result: &FollowResult) -> String {
247        self.format_follow(result)
248    }
249    fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
250        self.format_availability(result)
251    }
252    fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
253        self.format_dnssec(report)
254    }
255    fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
256        self.format_tld(info)
257    }
258    fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
259        self.format_dns_comparison(comparison)
260    }
261    fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
262        self.format_subdomains(result)
263    }
264    fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
265        self.format_diff(diff)
266    }
267    fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
268        self.format_ssl(report)
269    }
270    fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
271        self.format_watch(report)
272    }
273    fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
274        self.format_domain_info(info)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    fn formatter() -> HumanFormatter {
283        HumanFormatter::new().without_colors()
284    }
285
286    #[test]
287    fn expired_shows_days_ago() {
288        let f = formatter();
289        let out = f.format_expiry_status("2024-01-01", -3);
290        assert!(out.contains("expired 3 days ago"), "got: {}", out);
291        assert!(!out.contains("-3"), "got: {}", out);
292    }
293
294    #[test]
295    fn expiring_soon_shows_expires_in() {
296        let f = formatter();
297        let out = f.format_expiry_status("2026-05-01", 15);
298        assert!(out.contains("expires in 15 days"), "got: {}", out);
299        assert!(!out.contains("days ago"), "got: {}", out);
300    }
301
302    #[test]
303    fn warning_window_uses_expires_in() {
304        let f = formatter();
305        let out = f.format_expiry_status("2026-07-01", 60);
306        assert!(out.contains("expires in 60 days"), "got: {}", out);
307        assert!(!out.contains("!"), "got: {}", out);
308    }
309
310    #[test]
311    fn healthy_expiry_uses_expires_in() {
312        let f = formatter();
313        let out = f.format_expiry_status("2027-01-01", 300);
314        assert!(out.contains("expires in 300 days"), "got: {}", out);
315        assert!(!out.contains("!"), "got: {}", out);
316    }
317
318    #[test]
319    fn expired_one_day_is_pluralized_simply() {
320        // We don't singularize; verify the raw format.
321        let f = formatter();
322        let out = f.format_expiry_status("2024-01-01", -1);
323        assert!(out.contains("expired 1 days ago"), "got: {}", out);
324    }
325
326    #[test]
327    fn boundary_30_days_is_warning_not_error() {
328        let f = formatter();
329        // 30 days -> not <30, so warning branch, no "!"
330        let out = f.format_expiry_status("2026-05-15", 30);
331        assert!(out.contains("expires in 30 days"), "got: {}", out);
332        assert!(!out.contains("!"), "got: {}", out);
333    }
334}