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 security;
25mod status;
26mod whois;
27
28static ANSI_ESCAPE_RE: Lazy<Regex> = Lazy::new(|| {
33 Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[A-Z@-_]")
34 .expect("Invalid ANSI escape regex")
35});
36
37pub(super) fn sanitize_display(s: &str) -> String {
44 ANSI_ESCAPE_RE
45 .replace_all(s, "")
46 .chars()
47 .filter(|&c| c == '\n' || c == '\t' || !c.is_control())
48 .collect()
49}
50
51pub(crate) fn format_duration(duration: TimeDelta) -> String {
56 let total_secs = duration.num_seconds();
57 if total_secs < 60 {
58 format!("{}s", total_secs)
59 } else if total_secs < 3600 {
60 let mins = total_secs / 60;
61 let secs = total_secs % 60;
62 format!("{}m {}s", mins, secs)
63 } else {
64 let hours = total_secs / 3600;
65 let mins = (total_secs % 3600) / 60;
66 format!("{}h {}m", hours, mins)
67 }
68}
69
70pub struct HumanFormatter {
71 use_colors: bool,
72}
73
74impl Default for HumanFormatter {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80impl HumanFormatter {
81 pub fn new() -> Self {
82 Self { use_colors: true }
83 }
84
85 pub fn without_colors(mut self) -> Self {
86 self.use_colors = false;
87 self
88 }
89
90 fn label(&self, text: &str) -> String {
91 if self.use_colors {
92 text.sky().bold().to_string()
93 } else {
94 text.to_string()
95 }
96 }
97
98 fn value(&self, text: &str) -> String {
99 if self.use_colors {
100 text.ctp_white().to_string()
101 } else {
102 text.to_string()
103 }
104 }
105
106 fn success(&self, text: &str) -> String {
107 if self.use_colors {
108 text.ctp_green().bold().to_string()
109 } else {
110 text.to_string()
111 }
112 }
113
114 fn warning(&self, text: &str) -> String {
115 if self.use_colors {
116 text.ctp_yellow().bold().to_string()
117 } else {
118 text.to_string()
119 }
120 }
121
122 fn error(&self, text: &str) -> String {
123 if self.use_colors {
124 text.ctp_red().bold().to_string()
125 } else {
126 text.to_string()
127 }
128 }
129
130 fn dim(&self, text: &str) -> String {
131 if self.use_colors {
132 text.overlay1().to_string()
133 } else {
134 text.to_string()
135 }
136 }
137
138 fn header(&self, text: &str) -> String {
139 let width = text.chars().count();
144 if self.use_colors {
145 format!(
146 "\n{}\n{}",
147 text.lavender().bold(),
148 "─".repeat(width).subtext0()
149 )
150 } else {
151 format!("\n{}\n{}", text, "-".repeat(width))
152 }
153 }
154
155 fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
159 let mut out = Vec::new();
160 out.push(format!("\n{}{}:", indent, self.label("CAA Policy")));
161
162 if !caa.has_policy {
163 out.push(format!(
164 "{} {}",
165 indent,
166 self.value("No CAA records (any CA may issue)")
167 ));
168 } else {
169 if let Some(ref eff) = caa.effective_domain {
170 out.push(format!(
171 "{} {}: {}",
172 indent,
173 self.label("Found at"),
174 self.value(&sanitize_display(eff))
175 ));
176 }
177 for r in &caa.records {
178 out.push(format!(
179 "{} {} {} \"{}\"",
180 indent,
181 self.value(&r.flags.to_string()),
182 self.label(&r.tag),
183 sanitize_display(&r.value)
184 ));
185 }
186 }
187
188 if let Some(m) = caa.issuer_match {
189 let rendered = match m {
190 IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
191 IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
192 IssuerCaaMatch::Mismatch => self
193 .warning("issuer not in current CAA policy (informational — see note below)"),
194 IssuerCaaMatch::Indeterminate => {
195 self.warning("CAA present but no issue/issuewild tags")
196 }
197 };
198 out.push(format!(
199 "{} {}: {}",
200 indent,
201 self.label("Issuer vs CAA"),
202 rendered
203 ));
204 }
205
206 out
209 }
210
211 fn push_caa_note_footer(&self, out: &mut Vec<String>, caa: &CaaPolicy) {
216 out.push(String::new());
217 out.push(format!("note: {}", caa.note));
218 }
219
220 fn format_expiry_status(&self, expiry_str: &str, days_until: i64) -> String {
228 if days_until < 0 {
229 self.error(&format!(
230 "{} (expired {} days ago)",
231 expiry_str, -days_until
232 ))
233 } else if days_until < 30 {
234 self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
235 } else if days_until < 90 {
236 self.warning(&format!("{} (expires in {} days)", expiry_str, days_until))
237 } else {
238 self.success(&format!("{} (expires in {} days)", expiry_str, days_until))
239 }
240 }
241}
242
243impl OutputFormatter for HumanFormatter {
247 fn format_whois(&self, response: &WhoisResponse) -> String {
248 self.format_whois(response)
249 }
250 fn format_rdap(&self, response: &RdapResponse) -> String {
251 self.format_rdap(response)
252 }
253 fn format_dns(&self, records: &[DnsRecord]) -> String {
254 self.format_dns(records)
255 }
256 fn format_propagation(&self, result: &PropagationResult) -> String {
257 self.format_propagation(result)
258 }
259 fn format_lookup(&self, result: &LookupResult) -> String {
260 self.format_lookup(result)
261 }
262 fn format_status(&self, response: &StatusResponse) -> String {
263 self.format_status(response)
264 }
265 fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
266 self.format_follow_iteration(iteration)
267 }
268 fn format_follow(&self, result: &FollowResult) -> String {
269 self.format_follow(result)
270 }
271 fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
272 self.format_availability(result)
273 }
274 fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
275 self.format_dnssec(report)
276 }
277 fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
278 self.format_tld(info)
279 }
280 fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
281 self.format_dns_comparison(comparison)
282 }
283 fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
284 self.format_subdomains(result)
285 }
286 fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
287 self.format_diff(diff)
288 }
289 fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
290 self.format_ssl(report)
291 }
292 fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
293 self.format_watch(report)
294 }
295 fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
296 self.format_domain_info(info)
297 }
298 fn format_drift(&self, report: &crate::drift::DriftReport) -> String {
299 self.format_drift(report)
300 }
301 fn format_posture(&self, posture: &crate::posture::EmailPosture) -> String {
302 self.format_posture(posture)
303 }
304 fn format_caa(&self, policy: &CaaPolicy) -> String {
305 self.format_caa(policy)
306 }
307 fn format_confusables(&self, report: &crate::confusables::ConfusableReport) -> String {
308 self.format_confusables(report)
309 }
310 fn format_subdomain_classification(
311 &self,
312 result: &crate::subdomains::SubdomainClassification,
313 ) -> String {
314 self.format_subdomain_classification(result)
315 }
316 fn format_subdomain_baseline_diff(
317 &self,
318 report: &crate::subdomains::SubdomainBaselineDiff,
319 ) -> String {
320 self.format_subdomain_baseline_diff(report)
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 fn formatter() -> HumanFormatter {
329 HumanFormatter::new().without_colors()
330 }
331
332 #[test]
333 fn expired_shows_days_ago() {
334 let f = formatter();
335 let out = f.format_expiry_status("2024-01-01", -3);
336 assert!(out.contains("expired 3 days ago"), "got: {}", out);
337 assert!(!out.contains("-3"), "got: {}", out);
338 }
339
340 #[test]
341 fn expiring_soon_shows_expires_in() {
342 let f = formatter();
343 let out = f.format_expiry_status("2026-05-01", 15);
344 assert!(out.contains("expires in 15 days"), "got: {}", out);
345 assert!(!out.contains("days ago"), "got: {}", out);
346 }
347
348 #[test]
349 fn warning_window_uses_expires_in() {
350 let f = formatter();
351 let out = f.format_expiry_status("2026-07-01", 60);
352 assert!(out.contains("expires in 60 days"), "got: {}", out);
353 assert!(!out.contains("!"), "got: {}", out);
354 }
355
356 #[test]
357 fn healthy_expiry_uses_expires_in() {
358 let f = formatter();
359 let out = f.format_expiry_status("2027-01-01", 300);
360 assert!(out.contains("expires in 300 days"), "got: {}", out);
361 assert!(!out.contains("!"), "got: {}", out);
362 }
363
364 #[test]
365 fn expired_one_day_is_pluralized_simply() {
366 let f = formatter();
368 let out = f.format_expiry_status("2024-01-01", -1);
369 assert!(out.contains("expired 1 days ago"), "got: {}", out);
370 }
371
372 #[test]
373 fn boundary_30_days_is_warning_not_error() {
374 let f = formatter();
375 let out = f.format_expiry_status("2026-05-15", 30);
377 assert!(out.contains("expires in 30 days"), "got: {}", out);
378 assert!(!out.contains("!"), "got: {}", out);
379 }
380
381 #[test]
384 fn sanitize_display_strips_bare_control_chars_keeps_newline_tab() {
385 let evil = "a\rb\x08c\x07d\u{009b}e";
390 let clean = sanitize_display(evil);
391 for bad in ['\r', '\x08', '\x07', '\u{009b}', '\u{001b}'] {
392 assert!(
393 !clean.contains(bad),
394 "{bad:?} must be stripped from {clean:?}"
395 );
396 }
397 assert!(
398 clean.contains('a') && clean.contains('e'),
399 "text kept: {clean:?}"
400 );
401 assert_eq!(
402 sanitize_display("line1\nline2\tcol"),
403 "line1\nline2\tcol",
404 "newline and tab must be preserved"
405 );
406 }
407
408 #[test]
409 fn sanitize_display_strips_osc_with_st_terminator() {
410 let clean = sanitize_display("\x1b]0;malicious title\x1b\\visible");
413 assert_eq!(
414 clean, "visible",
415 "OSC+ST sequence must be fully removed: {clean:?}"
416 );
417 assert!(!clean.contains('\x1b'));
418 assert_eq!(sanitize_display("\x1b[31mred\x1b[0m"), "red");
420 }
421
422 #[test]
423 fn domain_info_renders_registrar_detail_and_lifecycle_fields() {
424 let whois = WhoisResponse::parse(
429 "example.com",
430 "whois.test",
431 "Registrar: Example Registrar\n\
432 Creation Date: 2020-01-01T00:00:00Z\n\
433 Registry Expiry Date: 2099-01-01T00:00:00Z\n\
434 Domain Status: clientTransferProhibited\n",
435 );
436 let mut info =
437 crate::domain_info::DomainInfo::from_sources("example.com", None, Some(&whois));
438 info.registrar_abuse_email = Some("abuse@registrar.test".to_string());
439 info.registrar_abuse_phone = Some("+1.5555550100".to_string());
440 info.registrar_iana_id = Some("9999".to_string());
441 info.registrar_url = Some("https://registrar.test".to_string());
442
443 assert!(info.days_until_expiration.is_some(), "lifecycle computed");
444 assert!(!info.status_descriptions.is_empty(), "status decoded");
445
446 let out = formatter().format_domain_info(&info);
447 for needle in [
448 "Registrar Detail",
449 "abuse@registrar.test",
450 "+1.5555550100",
451 "9999",
452 "https://registrar.test",
453 "Days Until Expiry",
454 "Domain Age",
455 "Expiry Status",
456 "clientTransferProhibited:",
457 ] {
458 assert!(out.contains(needle), "missing {needle:?} in:\n{out}");
459 }
460 }
461
462 #[test]
463 fn domain_info_sanitizes_nameserver_and_status_fields() {
464 let whois = WhoisResponse::parse(
468 "evil.example",
469 "whois.test",
470 "Name Server: ns1.evil\x1b[31mhidden\n\
471 Domain Status: ok\x1b]0;pwn\x07\n\
472 Registrar: Test Registrar\n\
473 Creation Date: 2020-01-01T00:00:00Z\n",
474 );
475 let info = crate::domain_info::DomainInfo::from_sources("evil.example", None, Some(&whois));
476 let out = formatter().format_domain_info(&info);
477 assert!(
478 !out.contains('\x1b'),
479 "ESC must not reach terminal: {out:?}"
480 );
481 assert!(
482 !out.contains('\x07'),
483 "BEL must not reach terminal: {out:?}"
484 );
485 }
486}