1use chrono::{DateTime, TimeDelta, Utc};
7use colored::ColoredString;
8
9use super::OutputFormatter;
10
11pub(super) use super::contact::{self, Contact, FlatContacts};
13pub(super) use super::days_until;
14pub(super) use super::grouping::render_grouped;
15pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
16pub(super) use crate::colors::CatppuccinExt;
17pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
18pub(super) use crate::lookup::LookupResult;
19pub(super) use crate::rdap::{ContactInfo, RdapResponse};
20pub(super) use crate::status::StatusResponse;
21pub(super) use crate::whois::WhoisResponse;
22pub(super) use colored::Colorize;
23
24mod delegation;
25mod diff;
26mod dns;
27mod domain_info;
28mod lookup;
29mod propagation;
30mod rdap;
31mod security;
32mod status;
33mod whois;
34
35static_regex! {
36 ANSI_ESCAPE_RE = r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[A-Z@-_]";
41}
42
43pub(super) fn sanitize_display(s: &str) -> String {
50 ANSI_ESCAPE_RE
51 .replace_all(s, "")
52 .chars()
53 .filter(|&c| c == '\n' || c == '\t' || !c.is_control())
54 .collect()
55}
56
57pub(crate) fn format_duration(duration: TimeDelta) -> String {
62 let total_secs = duration.num_seconds();
63 if total_secs < 60 {
64 format!("{}s", total_secs)
65 } else if total_secs < 3600 {
66 let mins = total_secs / 60;
67 let secs = total_secs % 60;
68 format!("{}m {}s", mins, secs)
69 } else {
70 let hours = total_secs / 3600;
71 let mins = (total_secs % 3600) / 60;
72 format!("{}h {}m", hours, mins)
73 }
74}
75
76pub(super) fn detail_views(contacts: &[Option<ContactInfo>; 3]) -> [Contact<'_>; 3] {
81 let [registrant, admin, tech] = contact::rdap_views(contacts);
82 [registrant.without_identity(), admin, tech]
83}
84
85pub struct HumanFormatter {
86 use_colors: bool,
87}
88
89impl Default for HumanFormatter {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95impl HumanFormatter {
96 pub fn new() -> Self {
97 Self { use_colors: true }
98 }
99
100 pub fn without_colors(mut self) -> Self {
101 self.use_colors = false;
102 self
103 }
104
105 fn paint(&self, text: &str, style: impl FnOnce(&str) -> ColoredString) -> String {
107 if self.use_colors {
108 style(text).to_string()
109 } else {
110 text.to_string()
111 }
112 }
113
114 fn label(&self, text: &str) -> String {
115 self.paint(text, |t| t.sky().bold())
116 }
117
118 fn value(&self, text: &str) -> String {
119 self.paint(text, |t| t.ctp_white())
120 }
121
122 fn success(&self, text: &str) -> String {
123 self.paint(text, |t| t.ctp_green().bold())
124 }
125
126 fn warning(&self, text: &str) -> String {
127 self.paint(text, |t| t.ctp_yellow().bold())
128 }
129
130 fn error(&self, text: &str) -> String {
131 self.paint(text, |t| t.ctp_red().bold())
132 }
133
134 fn dim(&self, text: &str) -> String {
135 self.paint(text, |t| t.overlay1())
136 }
137
138 fn rows<'a>(&'a self, out: &'a mut Vec<String>, indent: &str) -> Rows<'a> {
140 Rows {
141 f: self,
142 out,
143 indent: indent.to_string(),
144 }
145 }
146
147 fn header(&self, text: &str) -> String {
148 let width = text.chars().count();
153 if self.use_colors {
154 format!(
155 "\n{}\n{}",
156 text.lavender().bold(),
157 "─".repeat(width).subtext0()
158 )
159 } else {
160 format!("\n{}\n{}", text, "-".repeat(width))
161 }
162 }
163
164 fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
168 let mut out = Vec::new();
169 let mut block = self.rows(&mut out, indent);
170 let mut rows = block.section("CAA Policy");
171
172 if !caa.has_policy {
173 let line = format!(
174 "{}{}",
175 rows.indent,
176 self.value("No CAA records (any CA may issue)")
177 );
178 rows.push(line);
179 } else {
180 rows.opt("Found at", &caa.effective_domain);
181 for r in &caa.records {
182 let line = format!(
183 "{}{} {} \"{}\"",
184 rows.indent,
185 self.value(&r.flags.to_string()),
186 self.label(&r.tag),
187 sanitize_display(&r.value)
188 );
189 rows.push(line);
190 }
191 }
192
193 if let Some(m) = caa.issuer_match {
194 let rendered = match m {
195 IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
196 IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
197 IssuerCaaMatch::Mismatch => self
198 .warning("issuer not in current CAA policy (informational — see note below)"),
199 IssuerCaaMatch::Indeterminate => {
200 self.warning("CAA present but no issue/issuewild tags")
201 }
202 };
203 rows.kv("Issuer vs CAA", rendered);
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
243struct Rows<'a> {
246 f: &'a HumanFormatter,
247 out: &'a mut Vec<String>,
248 indent: String,
249}
250
251impl Rows<'_> {
252 fn push(&mut self, line: String) {
254 self.out.push(line);
255 }
256
257 fn extend(&mut self, lines: Vec<String>) {
259 self.out.extend(lines);
260 }
261
262 fn blank(&mut self) {
264 self.out.push(String::new());
265 }
266
267 fn kv(&mut self, label: &str, styled: String) {
270 let label = self.f.label(label);
271 self.out.push(format!("{}{label}: {styled}", self.indent));
272 }
273
274 fn text(&mut self, label: &str, text: &str) {
276 let value = self.f.value(&sanitize_display(text));
277 self.kv(label, value);
278 }
279
280 fn opt(&mut self, label: &str, text: &Option<String>) {
282 if let Some(text) = text {
283 self.text(label, text);
284 }
285 }
286
287 fn date(&mut self, label: &str, date: Option<DateTime<Utc>>) {
289 if let Some(date) = date {
290 let value = self.f.value(&date.format("%Y-%m-%d").to_string());
291 self.kv(label, value);
292 }
293 }
294
295 fn expires(&mut self, date: Option<DateTime<Utc>>) {
298 if let Some(date) = date {
299 let status = self
300 .f
301 .format_expiry_status(&date.format("%Y-%m-%d").to_string(), days_until(date));
302 self.kv("Expires", status);
303 }
304 }
305
306 fn list(&mut self, label: &str, items: &[String]) {
309 if items.is_empty() {
310 return;
311 }
312 let label = self.f.label(label);
313 self.out.push(format!("{}{label}:", self.indent));
314 for item in items {
315 let item = self.f.value(&sanitize_display(item));
316 self.out.push(format!("{} - {item}", self.indent));
317 }
318 }
319
320 fn at(&mut self, indent: &str) -> Rows<'_> {
322 self.f.rows(self.out, indent)
323 }
324
325 fn section(&mut self, label: &str) -> Rows<'_> {
328 let heading = self.f.label(label);
329 self.out.push(format!("\n{}{heading}:", self.indent));
330 let indent = format!("{} ", self.indent);
331 self.at(&indent)
332 }
333
334 fn contact(&mut self, role: &str, contact: Contact<'_>) {
337 if contact.is_empty() {
338 return;
339 }
340 let mut rows = self.section(&format!("{role} Contact"));
341 for (label, field) in contact.fields() {
342 rows.opt(label, field);
343 }
344 }
345
346 fn contacts(&mut self, contacts: [Contact<'_>; 3]) {
348 for (role, c) in contact::ROLES.into_iter().zip(contacts) {
349 self.contact(role, c);
350 }
351 }
352}
353
354with_report_methods!(impl_forwarding!(HumanFormatter;));
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 fn formatter() -> HumanFormatter {
361 HumanFormatter::new().without_colors()
362 }
363
364 #[test]
365 fn expired_shows_days_ago() {
366 let f = formatter();
367 let out = f.format_expiry_status("2024-01-01", -3);
368 assert!(out.contains("expired 3 days ago"), "got: {}", out);
369 assert!(!out.contains("-3"), "got: {}", out);
370 }
371
372 #[test]
373 fn expiring_soon_shows_expires_in() {
374 let f = formatter();
375 let out = f.format_expiry_status("2026-05-01", 15);
376 assert!(out.contains("expires in 15 days"), "got: {}", out);
377 assert!(!out.contains("days ago"), "got: {}", out);
378 }
379
380 #[test]
381 fn warning_window_uses_expires_in() {
382 let f = formatter();
383 let out = f.format_expiry_status("2026-07-01", 60);
384 assert!(out.contains("expires in 60 days"), "got: {}", out);
385 assert!(!out.contains("!"), "got: {}", out);
386 }
387
388 #[test]
389 fn healthy_expiry_uses_expires_in() {
390 let f = formatter();
391 let out = f.format_expiry_status("2027-01-01", 300);
392 assert!(out.contains("expires in 300 days"), "got: {}", out);
393 assert!(!out.contains("!"), "got: {}", out);
394 }
395
396 #[test]
397 fn expired_one_day_is_pluralized_simply() {
398 let f = formatter();
400 let out = f.format_expiry_status("2024-01-01", -1);
401 assert!(out.contains("expired 1 days ago"), "got: {}", out);
402 }
403
404 #[test]
405 fn boundary_30_days_is_warning_not_error() {
406 let f = formatter();
407 let out = f.format_expiry_status("2026-05-15", 30);
409 assert!(out.contains("expires in 30 days"), "got: {}", out);
410 assert!(!out.contains("!"), "got: {}", out);
411 }
412
413 #[test]
416 fn sanitize_display_strips_bare_control_chars_keeps_newline_tab() {
417 let evil = "a\rb\x08c\x07d\u{009b}e";
422 let clean = sanitize_display(evil);
423 for bad in ['\r', '\x08', '\x07', '\u{009b}', '\u{001b}'] {
424 assert!(
425 !clean.contains(bad),
426 "{bad:?} must be stripped from {clean:?}"
427 );
428 }
429 assert!(
430 clean.contains('a') && clean.contains('e'),
431 "text kept: {clean:?}"
432 );
433 assert_eq!(
434 sanitize_display("line1\nline2\tcol"),
435 "line1\nline2\tcol",
436 "newline and tab must be preserved"
437 );
438 }
439
440 #[test]
441 fn sanitize_display_strips_osc_with_st_terminator() {
442 let clean = sanitize_display("\x1b]0;malicious title\x1b\\visible");
445 assert_eq!(
446 clean, "visible",
447 "OSC+ST sequence must be fully removed: {clean:?}"
448 );
449 assert!(!clean.contains('\x1b'));
450 assert_eq!(sanitize_display("\x1b[31mred\x1b[0m"), "red");
452 }
453
454 #[test]
455 fn domain_info_renders_registrar_detail_and_lifecycle_fields() {
456 let whois = WhoisResponse::parse(
461 "example.com",
462 "whois.test",
463 "Registrar: Example Registrar\n\
464 Creation Date: 2020-01-01T00:00:00Z\n\
465 Registry Expiry Date: 2099-01-01T00:00:00Z\n\
466 Domain Status: clientTransferProhibited\n",
467 );
468 let mut info =
469 crate::domain_info::DomainInfo::from_sources("example.com", None, Some(&whois));
470 info.registrar_abuse_email = Some("abuse@registrar.test".to_string());
471 info.registrar_abuse_phone = Some("+1.5555550100".to_string());
472 info.registrar_iana_id = Some("9999".to_string());
473 info.registrar_url = Some("https://registrar.test".to_string());
474
475 assert!(info.days_until_expiration.is_some(), "lifecycle computed");
476 assert!(!info.status_descriptions.is_empty(), "status decoded");
477
478 let out = formatter().format_domain_info(&info);
479 for needle in [
480 "Registrar Detail",
481 "abuse@registrar.test",
482 "+1.5555550100",
483 "9999",
484 "https://registrar.test",
485 "Days Until Expiry",
486 "Domain Age",
487 "Expiry Status",
488 "clientTransferProhibited:",
489 ] {
490 assert!(out.contains(needle), "missing {needle:?} in:\n{out}");
491 }
492 }
493
494 #[test]
495 fn domain_info_sanitizes_nameserver_and_status_fields() {
496 let whois = WhoisResponse::parse(
500 "evil.example",
501 "whois.test",
502 "Name Server: ns1.evil\x1b[31mhidden\n\
503 Domain Status: ok\x1b]0;pwn\x07\n\
504 Registrar: Test Registrar\n\
505 Creation Date: 2020-01-01T00:00:00Z\n",
506 );
507 let info = crate::domain_info::DomainInfo::from_sources("evil.example", None, Some(&whois));
508 let out = formatter().format_domain_info(&info);
509 assert!(
510 !out.contains('\x1b'),
511 "ESC must not reach terminal: {out:?}"
512 );
513 assert!(
514 !out.contains('\x07'),
515 "BEL must not reach terminal: {out:?}"
516 );
517 }
518}