1use chrono::{DateTime, Utc};
9use colored::ColoredString;
10
11use super::OutputFormatter;
12
13pub(super) use super::contact::{self, Contact, FlatContacts};
15pub(super) use super::grouping::render_grouped;
16pub(super) use super::{
17 day_count, days_until, dnssec_depth, expiry_emphasis, expiry_phrase, format_duration,
18 propagation_detail, propagation_difference, sanitize_line, Emphasis, DNSSEC_NOTE, GEO_NOTE,
19};
20pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
21pub(super) use crate::colors::CatppuccinExt;
22pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
23pub(super) use crate::lookup::LookupResult;
24pub(super) use crate::rdap::RdapResponse;
25pub(super) use crate::status::StatusResponse;
26pub(super) use crate::whois::WhoisResponse;
27pub(super) use colored::Colorize;
28
29mod delegation;
30mod diff;
31mod dig;
32mod dns;
33mod domain_info;
34mod lookup;
35mod propagation;
36mod rdap;
37mod security;
38mod status;
39mod whois;
40
41pub struct HumanFormatter {
42 use_colors: bool,
43}
44
45impl Default for HumanFormatter {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl HumanFormatter {
52 pub fn new() -> Self {
53 Self { use_colors: true }
54 }
55
56 pub fn without_colors(mut self) -> Self {
57 self.use_colors = false;
58 self
59 }
60
61 fn paint(&self, text: &str, style: impl FnOnce(&str) -> ColoredString) -> String {
63 if self.use_colors {
64 style(text).to_string()
65 } else {
66 text.to_string()
67 }
68 }
69
70 fn label(&self, text: &str) -> String {
71 self.paint(text, |t| t.sky().bold())
72 }
73
74 fn value(&self, text: &str) -> String {
75 self.paint(text, |t| t.ctp_white())
76 }
77
78 fn success(&self, text: &str) -> String {
79 self.paint(text, |t| t.ctp_green().bold())
80 }
81
82 fn warning(&self, text: &str) -> String {
83 self.paint(text, |t| t.ctp_yellow().bold())
84 }
85
86 fn error(&self, text: &str) -> String {
87 self.paint(text, |t| t.ctp_red().bold())
88 }
89
90 fn dim(&self, text: &str) -> String {
91 self.paint(text, |t| t.overlay1())
92 }
93
94 fn emphasize(&self, text: &str, emphasis: Emphasis) -> String {
96 match emphasis {
97 Emphasis::Good => self.success(text),
98 Emphasis::Neutral => self.value(text),
99 Emphasis::Caution => self.warning(text),
100 Emphasis::Bad => self.error(text),
101 }
102 }
103
104 fn rows<'a>(&'a self, out: &'a mut Vec<String>, indent: &str) -> Rows<'a> {
106 Rows {
107 f: self,
108 out,
109 indent: indent.to_string(),
110 }
111 }
112
113 fn header(&self, text: &str) -> String {
114 let width = text.chars().count();
119 if self.use_colors {
120 format!(
121 "\n{}\n{}",
122 text.lavender().bold(),
123 "─".repeat(width).subtext0()
124 )
125 } else {
126 format!("\n{}\n{}", text, "-".repeat(width))
127 }
128 }
129
130 fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
134 let mut out = Vec::new();
135 let mut block = self.rows(&mut out, indent);
136 let mut rows = block.section("CAA Policy");
137
138 if !caa.has_policy {
139 let line = format!(
140 "{}{}",
141 rows.indent,
142 self.value("No CAA records (any CA may issue)")
143 );
144 rows.push(line);
145 } else {
146 rows.opt("Found at", &caa.effective_domain);
147 for r in &caa.records {
148 let line = format!(
149 "{}{} {} \"{}\"",
150 rows.indent,
151 self.value(&r.flags.to_string()),
152 self.label(&sanitize_line(&r.tag)),
153 sanitize_line(&r.value)
154 );
155 rows.push(line);
156 }
157 }
158
159 if let Some(m) = caa.issuer_match {
160 let rendered = match m {
161 IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
162 IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
163 IssuerCaaMatch::Mismatch => self
164 .warning("issuer not in current CAA policy (informational — see note below)"),
165 IssuerCaaMatch::Indeterminate => {
166 self.warning("CAA present but no issue/issuewild tags")
167 }
168 };
169 rows.kv("Issuer vs CAA", rendered);
170 }
171
172 out
175 }
176
177 fn push_caa_note_footer(&self, out: &mut Vec<String>, caa: &CaaPolicy) {
182 out.push(String::new());
183 out.push(format!("note: {}", caa.note));
184 }
185
186 fn format_expiry_status(&self, date: DateTime<Utc>, days_until: i64) -> String {
189 let text = format!(
190 "{} ({})",
191 date.format("%Y-%m-%d"),
192 expiry_phrase(days_until)
193 );
194 self.emphasize(&text, expiry_emphasis(days_until))
195 }
196
197 fn expiry_countdown(&self, days_until: i64) -> String {
199 self.emphasize(&expiry_phrase(days_until), expiry_emphasis(days_until))
200 }
201}
202
203struct Rows<'a> {
207 f: &'a HumanFormatter,
208 out: &'a mut Vec<String>,
209 indent: String,
210}
211
212impl Rows<'_> {
213 fn push(&mut self, line: String) {
215 self.out.push(line);
216 }
217
218 fn extend(&mut self, lines: Vec<String>) {
220 self.out.extend(lines);
221 }
222
223 fn blank(&mut self) {
225 self.out.push(String::new());
226 }
227
228 fn kv(&mut self, label: &str, styled: String) {
231 let label = self.f.label(label);
232 self.out.push(format!("{}{label}: {styled}", self.indent));
233 }
234
235 fn text(&mut self, label: &str, text: &str) {
237 let value = self.f.value(&sanitize_line(text));
238 self.kv(label, value);
239 }
240
241 fn opt(&mut self, label: &str, text: &Option<String>) {
243 if let Some(text) = text {
244 self.text(label, text);
245 }
246 }
247
248 fn date(&mut self, label: &str, date: Option<DateTime<Utc>>) {
250 if let Some(date) = date {
251 let value = self.f.value(&date.format("%Y-%m-%d").to_string());
252 self.kv(label, value);
253 }
254 }
255
256 fn expires(&mut self, date: Option<DateTime<Utc>>) {
259 if let Some(date) = date {
260 let status = self.f.format_expiry_status(date, days_until(date));
261 self.kv("Expires", status);
262 }
263 }
264
265 fn list(&mut self, label: &str, items: &[String]) {
268 if items.is_empty() {
269 return;
270 }
271 let label = self.f.label(label);
272 self.out.push(format!("{}{label}:", self.indent));
273 for item in items {
274 let item = self.f.value(&sanitize_line(item));
275 self.out.push(format!("{} - {item}", self.indent));
276 }
277 }
278
279 fn at(&mut self, indent: &str) -> Rows<'_> {
281 self.f.rows(self.out, indent)
282 }
283
284 fn section(&mut self, label: &str) -> Rows<'_> {
287 let heading = self.f.label(label);
288 self.out.push(format!("\n{}{heading}:", self.indent));
289 let indent = format!("{} ", self.indent);
290 self.at(&indent)
291 }
292
293 fn contact(&mut self, role: &str, contact: Contact<'_>) {
296 if contact.is_empty() {
297 return;
298 }
299 let mut rows = self.section(&format!("{role} Contact"));
300 for (label, field) in contact.fields() {
301 rows.opt(label, field);
302 }
303 }
304
305 fn contacts(&mut self, contacts: [Contact<'_>; 3]) {
307 for (role, c) in contact::ROLES.into_iter().zip(contacts) {
308 self.contact(role, c);
309 }
310 }
311}
312
313with_report_methods!(impl_forwarding!(HumanFormatter;));
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 fn formatter() -> HumanFormatter {
320 HumanFormatter::new().without_colors()
321 }
322
323 fn date(s: &str) -> DateTime<Utc> {
324 s.parse().unwrap()
325 }
326
327 #[test]
328 fn expiry_status_says_expired_days_ago_never_a_negative_count() {
329 let out = formatter().format_expiry_status(date("2024-01-01T00:00:00Z"), -3);
330 assert_eq!(out, "2024-01-01 (expired 3 days ago)");
331 let out = formatter().format_expiry_status(date("2024-01-01T00:00:00Z"), -1);
332 assert_eq!(out, "2024-01-01 (expired 1 day ago)");
333 }
334
335 #[test]
336 fn expiry_status_counts_down_in_every_urgency_band() {
337 for days in [0, 15, 30, 60, 300] {
338 let out = formatter().format_expiry_status(date("2027-01-01T00:00:00Z"), days);
339 let expected = if days == 1 {
340 "1 day".into()
341 } else {
342 format!("{days} days")
343 };
344 assert_eq!(out, format!("2027-01-01 (expires in {expected})"));
345 }
346 }
347
348 #[test]
349 fn expiry_status_is_colored_by_urgency() {
350 colored::control::set_override(true);
351 let f = HumanFormatter::new();
352 let when = date("2027-01-01T00:00:00Z");
353 let (red, yellow, green) = ("\x1b[1;91m", "\x1b[1;93m", "\x1b[1;92m");
354 assert!(f.format_expiry_status(when, -3).starts_with(red));
355 assert!(f.format_expiry_status(when, 29).starts_with(red));
356 assert!(f.format_expiry_status(when, 30).starts_with(yellow));
357 assert!(f.format_expiry_status(when, 90).starts_with(green));
358 colored::control::unset_override();
359 }
360
361 #[test]
362 fn rows_fold_remote_newlines_so_a_value_cannot_forge_a_row() {
363 let mut whois = WhoisResponse::parse("example.com", "whois.test", "Registrar: R\n");
366 whois.registrar =
367 Some("Evil Registrar\n Expires: 2099-01-01 (expires in 9999 days)".into());
368 whois.nameservers = vec!["ns1.example\n Status: ok".into()];
369 let out = formatter().format_whois(&whois);
370 assert!(
371 out.contains(" Registrar: Evil Registrar Expires: 2099-01-01"),
372 "got:\n{out}"
373 );
374 assert!(
375 !out.lines()
376 .any(|l| l.trim_start().starts_with("Expires: 2099")),
377 "forged row:\n{out}"
378 );
379 assert!(
380 !out.lines()
381 .any(|l| l.trim_start().starts_with("Status: ok")),
382 "forged list row:\n{out}"
383 );
384 }
385
386 #[test]
387 fn domain_info_renders_registrar_detail_and_lifecycle_fields() {
388 let whois = WhoisResponse::parse(
393 "example.com",
394 "whois.test",
395 "Registrar: Example Registrar\n\
396 Creation Date: 2020-01-01T00:00:00Z\n\
397 Registry Expiry Date: 2099-01-01T00:00:00Z\n\
398 Domain Status: clientTransferProhibited\n",
399 );
400 let mut info =
401 crate::domain_info::DomainInfo::from_sources("example.com", None, Some(&whois));
402 info.registrar_abuse_email = Some("abuse@registrar.test".to_string());
403 info.registrar_abuse_phone = Some("+1.5555550100".to_string());
404 info.registrar_iana_id = Some("9999".to_string());
405 info.registrar_url = Some("https://registrar.test".to_string());
406
407 assert!(info.days_until_expiration.is_some(), "lifecycle computed");
408 assert!(!info.status_descriptions.is_empty(), "status decoded");
409
410 let out = formatter().format_domain_info(&info);
411 for needle in [
412 "Registrar Detail",
413 "abuse@registrar.test",
414 "+1.5555550100",
415 "9999",
416 "https://registrar.test",
417 "Days Until Expiry",
418 "Domain Age",
419 "Expiry Status",
420 "clientTransferProhibited:",
421 ] {
422 assert!(out.contains(needle), "missing {needle:?} in:\n{out}");
423 }
424 }
425
426 #[test]
427 fn domain_info_sanitizes_nameserver_and_status_fields() {
428 let whois = WhoisResponse::parse(
432 "evil.example",
433 "whois.test",
434 "Name Server: ns1.evil\x1b[31mhidden\n\
435 Domain Status: ok\x1b]0;pwn\x07\n\
436 Registrar: Test Registrar\n\
437 Creation Date: 2020-01-01T00:00:00Z\n",
438 );
439 let info = crate::domain_info::DomainInfo::from_sources("evil.example", None, Some(&whois));
440 let out = formatter().format_domain_info(&info);
441 assert!(
442 !out.contains('\x1b'),
443 "ESC must not reach terminal: {out:?}"
444 );
445 assert!(
446 !out.contains('\x07'),
447 "BEL must not reach terminal: {out:?}"
448 );
449 }
450}