1use chrono::TimeDelta;
2use once_cell::sync::Lazy;
3use regex::Regex;
4
5use super::OutputFormatter;
6
7pub(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
27static ANSI_ESCAPE_RE: Lazy<Regex> = Lazy::new(|| {
32 Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[A-Z@-_]")
33 .expect("Invalid ANSI escape regex")
34});
35
36pub(super) fn sanitize_display(s: &str) -> String {
43 ANSI_ESCAPE_RE
44 .replace_all(s, "")
45 .chars()
46 .filter(|&c| c == '\n' || c == '\t' || !c.is_control())
47 .collect()
48}
49
50pub(super) fn format_duration(duration: TimeDelta) -> String {
51 let total_secs = duration.num_seconds();
52 if total_secs < 60 {
53 format!("{}s", total_secs)
54 } else if total_secs < 3600 {
55 let mins = total_secs / 60;
56 let secs = total_secs % 60;
57 format!("{}m {}s", mins, secs)
58 } else {
59 let hours = total_secs / 3600;
60 let mins = (total_secs % 3600) / 60;
61 format!("{}h {}m", hours, mins)
62 }
63}
64
65pub struct HumanFormatter {
66 use_colors: bool,
67}
68
69impl Default for HumanFormatter {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75impl HumanFormatter {
76 pub fn new() -> Self {
77 Self { use_colors: true }
78 }
79
80 pub fn without_colors(mut self) -> Self {
81 self.use_colors = false;
82 self
83 }
84
85 fn label(&self, text: &str) -> String {
86 if self.use_colors {
87 text.sky().bold().to_string()
88 } else {
89 text.to_string()
90 }
91 }
92
93 fn value(&self, text: &str) -> String {
94 if self.use_colors {
95 text.ctp_white().to_string()
96 } else {
97 text.to_string()
98 }
99 }
100
101 fn success(&self, text: &str) -> String {
102 if self.use_colors {
103 text.ctp_green().bold().to_string()
104 } else {
105 text.to_string()
106 }
107 }
108
109 fn warning(&self, text: &str) -> String {
110 if self.use_colors {
111 text.ctp_yellow().bold().to_string()
112 } else {
113 text.to_string()
114 }
115 }
116
117 fn error(&self, text: &str) -> String {
118 if self.use_colors {
119 text.ctp_red().bold().to_string()
120 } else {
121 text.to_string()
122 }
123 }
124
125 fn dim(&self, text: &str) -> String {
126 if self.use_colors {
127 text.overlay1().to_string()
128 } else {
129 text.to_string()
130 }
131 }
132
133 fn header(&self, text: &str) -> String {
134 let width = text.chars().count();
139 if self.use_colors {
140 format!(
141 "\n{}\n{}",
142 text.lavender().bold(),
143 "─".repeat(width).subtext0()
144 )
145 } else {
146 format!("\n{}\n{}", text, "-".repeat(width))
147 }
148 }
149
150 fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
154 let mut out = Vec::new();
155 out.push(format!("\n{}{}:", indent, self.label("CAA Policy")));
156
157 if !caa.has_policy {
158 out.push(format!(
159 "{} {}",
160 indent,
161 self.value("No CAA records (any CA may issue)")
162 ));
163 } else {
164 if let Some(ref eff) = caa.effective_domain {
165 out.push(format!(
166 "{} {}: {}",
167 indent,
168 self.label("Found at"),
169 self.value(&sanitize_display(eff))
170 ));
171 }
172 for r in &caa.records {
173 out.push(format!(
174 "{} {} {} \"{}\"",
175 indent,
176 self.value(&r.flags.to_string()),
177 self.label(&r.tag),
178 sanitize_display(&r.value)
179 ));
180 }
181 }
182
183 if let Some(m) = caa.issuer_match {
184 let rendered = match m {
185 IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
186 IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
187 IssuerCaaMatch::Mismatch => self
188 .warning("issuer not in current CAA policy (informational — see note below)"),
189 IssuerCaaMatch::Indeterminate => {
190 self.warning("CAA present but no issue/issuewild tags")
191 }
192 };
193 out.push(format!(
194 "{} {}: {}",
195 indent,
196 self.label("Issuer vs CAA"),
197 rendered
198 ));
199 }
200
201 out
204 }
205
206 fn push_caa_note_footer(&self, out: &mut Vec<String>, caa: &CaaPolicy) {
211 out.push(String::new());
212 out.push(format!("note: {}", caa.note));
213 }
214
215 fn format_expiry_status(&self, expiry_str: &str, days_until: i64) -> String {
223 if days_until < 0 {
224 self.error(&format!(
225 "{} (expired {} days ago)",
226 expiry_str, -days_until
227 ))
228 } else if days_until < 30 {
229 self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
230 } else if days_until < 90 {
231 self.warning(&format!("{} (expires in {} days)", expiry_str, days_until))
232 } else {
233 self.success(&format!("{} (expires in {} days)", expiry_str, days_until))
234 }
235 }
236}
237
238impl OutputFormatter for HumanFormatter {
242 fn format_whois(&self, response: &WhoisResponse) -> String {
243 self.format_whois(response)
244 }
245 fn format_rdap(&self, response: &RdapResponse) -> String {
246 self.format_rdap(response)
247 }
248 fn format_dns(&self, records: &[DnsRecord]) -> String {
249 self.format_dns(records)
250 }
251 fn format_propagation(&self, result: &PropagationResult) -> String {
252 self.format_propagation(result)
253 }
254 fn format_lookup(&self, result: &LookupResult) -> String {
255 self.format_lookup(result)
256 }
257 fn format_status(&self, response: &StatusResponse) -> String {
258 self.format_status(response)
259 }
260 fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
261 self.format_follow_iteration(iteration)
262 }
263 fn format_follow(&self, result: &FollowResult) -> String {
264 self.format_follow(result)
265 }
266 fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
267 self.format_availability(result)
268 }
269 fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
270 self.format_dnssec(report)
271 }
272 fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
273 self.format_tld(info)
274 }
275 fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
276 self.format_dns_comparison(comparison)
277 }
278 fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
279 self.format_subdomains(result)
280 }
281 fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
282 self.format_diff(diff)
283 }
284 fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
285 self.format_ssl(report)
286 }
287 fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
288 self.format_watch(report)
289 }
290 fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
291 self.format_domain_info(info)
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn formatter() -> HumanFormatter {
300 HumanFormatter::new().without_colors()
301 }
302
303 #[test]
304 fn expired_shows_days_ago() {
305 let f = formatter();
306 let out = f.format_expiry_status("2024-01-01", -3);
307 assert!(out.contains("expired 3 days ago"), "got: {}", out);
308 assert!(!out.contains("-3"), "got: {}", out);
309 }
310
311 #[test]
312 fn expiring_soon_shows_expires_in() {
313 let f = formatter();
314 let out = f.format_expiry_status("2026-05-01", 15);
315 assert!(out.contains("expires in 15 days"), "got: {}", out);
316 assert!(!out.contains("days ago"), "got: {}", out);
317 }
318
319 #[test]
320 fn warning_window_uses_expires_in() {
321 let f = formatter();
322 let out = f.format_expiry_status("2026-07-01", 60);
323 assert!(out.contains("expires in 60 days"), "got: {}", out);
324 assert!(!out.contains("!"), "got: {}", out);
325 }
326
327 #[test]
328 fn healthy_expiry_uses_expires_in() {
329 let f = formatter();
330 let out = f.format_expiry_status("2027-01-01", 300);
331 assert!(out.contains("expires in 300 days"), "got: {}", out);
332 assert!(!out.contains("!"), "got: {}", out);
333 }
334
335 #[test]
336 fn expired_one_day_is_pluralized_simply() {
337 let f = formatter();
339 let out = f.format_expiry_status("2024-01-01", -1);
340 assert!(out.contains("expired 1 days ago"), "got: {}", out);
341 }
342
343 #[test]
344 fn boundary_30_days_is_warning_not_error() {
345 let f = formatter();
346 let out = f.format_expiry_status("2026-05-15", 30);
348 assert!(out.contains("expires in 30 days"), "got: {}", out);
349 assert!(!out.contains("!"), "got: {}", out);
350 }
351
352 #[test]
355 fn sanitize_display_strips_bare_control_chars_keeps_newline_tab() {
356 let evil = "a\rb\x08c\x07d\u{009b}e";
361 let clean = sanitize_display(evil);
362 for bad in ['\r', '\x08', '\x07', '\u{009b}', '\u{001b}'] {
363 assert!(
364 !clean.contains(bad),
365 "{bad:?} must be stripped from {clean:?}"
366 );
367 }
368 assert!(
369 clean.contains('a') && clean.contains('e'),
370 "text kept: {clean:?}"
371 );
372 assert_eq!(
373 sanitize_display("line1\nline2\tcol"),
374 "line1\nline2\tcol",
375 "newline and tab must be preserved"
376 );
377 }
378
379 #[test]
380 fn sanitize_display_strips_osc_with_st_terminator() {
381 let clean = sanitize_display("\x1b]0;malicious title\x1b\\visible");
384 assert_eq!(
385 clean, "visible",
386 "OSC+ST sequence must be fully removed: {clean:?}"
387 );
388 assert!(!clean.contains('\x1b'));
389 assert_eq!(sanitize_display("\x1b[31mred\x1b[0m"), "red");
391 }
392
393 #[test]
394 fn domain_info_sanitizes_nameserver_and_status_fields() {
395 let whois = WhoisResponse::parse(
399 "evil.example",
400 "whois.test",
401 "Name Server: ns1.evil\x1b[31mhidden\n\
402 Domain Status: ok\x1b]0;pwn\x07\n\
403 Registrar: Test Registrar\n\
404 Creation Date: 2020-01-01T00:00:00Z\n",
405 );
406 let info = crate::domain_info::DomainInfo::from_sources("evil.example", None, Some(&whois));
407 let out = formatter().format_domain_info(&info);
408 assert!(
409 !out.contains('\x1b'),
410 "ESC must not reach terminal: {out:?}"
411 );
412 assert!(
413 !out.contains('\x07'),
414 "BEL must not reach terminal: {out:?}"
415 );
416 }
417}