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