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