1use std::fmt::{self, Write as _};
9
10use chrono::{DateTime, Utc};
11
12use super::OutputFormatter;
13
14pub(super) use super::contact::{self, Contact, FlatContacts};
16pub(super) use super::grouping::render_grouped;
17pub(super) use super::{
18 days_until, dnssec_depth, expiry_phrase, format_duration, propagation_detail,
19 propagation_difference, DNSSEC_NOTE, GEO_NOTE,
20};
21pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
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;
27
28mod delegation;
29mod diff;
30mod dig;
31mod dns;
32mod domain_info;
33mod lookup;
34mod propagation;
35mod rdap;
36mod security;
37mod status;
38mod whois;
39
40#[derive(Clone, Copy, PartialEq, Eq)]
42enum Context {
43 Text,
46 Code,
50 CodeCell,
53}
54
55fn write_escaped(f: &mut fmt::Formatter<'_>, text: &str, context: Context) -> fmt::Result {
73 let mut iter = text.chars().peekable();
74 while let Some(c) = iter.next() {
75 match c {
76 '\x1b' => consume_escape(&mut iter),
77 '\n' | '\r' | '\t' | '\u{2028}' | '\u{2029}' => f.write_str(" ")?,
78 '`' => f.write_str("'")?,
79 '\\' | '[' | ']' | '<' | '>' | '|' if context == Context::Text => {
80 f.write_char('\\')?;
81 f.write_char(c)?;
82 }
83 '|' if context == Context::CodeCell => f.write_str("\\|")?,
84 '\\' if context == Context::CodeCell && iter.peek() == Some(&'|') => {
85 f.write_str("\\\\")?;
86 }
87 c if c.is_control() || super::is_invisible_format(c) => {}
88 c => f.write_char(c)?,
89 }
90 }
91 Ok(())
92}
93
94pub(super) struct MdSafe<'a>(pub &'a str);
96
97impl fmt::Display for MdSafe<'_> {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 write_escaped(f, self.0, Context::Text)
100 }
101}
102
103pub(super) struct MdCode<'a>(pub &'a str);
106
107impl fmt::Display for MdCode<'_> {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 write_escaped(f, self.0, Context::Code)
110 }
111}
112
113pub(super) struct MdCodeCell<'a>(pub &'a str);
116
117impl fmt::Display for MdCodeCell<'_> {
118 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119 write_escaped(f, self.0, Context::CodeCell)
120 }
121}
122
123const ANSI_SCAN_CAP: usize = 64;
127
128fn consume_escape(iter: &mut std::iter::Peekable<std::str::Chars<'_>>) {
138 match iter.peek() {
139 Some('[') => {
141 iter.next(); for _ in 0..ANSI_SCAN_CAP {
143 match iter.peek() {
144 Some('\n') | Some('\r') | None => break,
147 Some(&ch) => {
148 iter.next();
149 if matches!(ch as u32, 0x40..=0x7E) {
150 break;
151 }
152 }
153 }
154 }
155 }
156 Some(']') | Some('P') => {
159 iter.next(); for _ in 0..ANSI_SCAN_CAP {
161 match iter.peek() {
162 Some('\n') | Some('\r') | None => break,
163 Some('\x07') => {
164 iter.next();
165 break;
166 }
167 Some('\x1b') => {
168 iter.next();
170 if iter.peek() == Some(&'\\') {
171 iter.next();
172 }
173 break;
174 }
175 Some(_) => {
176 iter.next();
177 }
178 }
179 }
180 }
181 Some('(') | Some(')') | Some('*') | Some('+') => {
185 iter.next(); if !matches!(iter.peek(), Some('\n') | Some('\r') | None) {
187 iter.next();
188 }
189 }
190 _ => {}
193 }
194}
195
196#[derive(Default)]
198pub struct MarkdownFormatter;
199
200impl MarkdownFormatter {
201 pub fn new() -> Self {
202 Self
203 }
204
205 fn render_caa_section(&self, caa: &CaaPolicy) -> Vec<String> {
208 let mut out = vec![String::new(), "### CAA Policy".to_string(), String::new()];
209 caa_body(&mut out, caa);
210 out.extend([String::new(), format!("> **Note:** {}", caa.note)]);
211 out
212 }
213}
214
215fn caa_body(out: &mut Vec<String>, caa: &CaaPolicy) {
218 if !caa.has_policy {
219 out.push("*No CAA records (any CA may issue)*".to_string());
220 } else {
221 Bullets(out).code_opt("Found at", &caa.effective_domain);
222 out.push(String::new());
223 out.push("| Flags | Tag | Value |".to_string());
224 out.push("| --- | --- | --- |".to_string());
225 for r in &caa.records {
226 out.push(format!(
227 "| {} | `{}` | `{}` |",
228 r.flags,
229 MdCodeCell(&r.tag),
230 MdCodeCell(&r.value)
231 ));
232 }
233 }
234
235 if let Some(m) = caa.issuer_match {
236 let rendered = match m {
237 IssuerCaaMatch::NoPolicy => "no policy — any CA permitted",
238 IssuerCaaMatch::Permitted => "issuer permitted by current CAA policy",
239 IssuerCaaMatch::Mismatch => "issuer not in current CAA policy (informational)",
240 IssuerCaaMatch::Indeterminate => "CAA present but no issue/issuewild tags",
241 };
242 out.push(String::new());
243 Bullets(out).raw("Issuer vs CAA", rendered);
244 }
245}
246
247fn code_list<S: AsRef<str>>(items: &[S]) -> String {
251 items
252 .iter()
253 .map(|item| format!("`{}`", MdCode(item.as_ref())))
254 .collect::<Vec<_>>()
255 .join(", ")
256}
257
258fn code_cells<S: AsRef<str>>(items: &[S]) -> String {
260 items
261 .iter()
262 .map(|item| format!("`{}`", MdCodeCell(item.as_ref())))
263 .collect::<Vec<_>>()
264 .join(", ")
265}
266
267fn record_table<'r>(out: &mut Vec<String>, records: impl IntoIterator<Item = &'r DnsRecord>) {
270 out.push("| Name | TTL | Type | Data |".to_string());
271 out.push("| --- | --- | --- | --- |".to_string());
272 for record in records {
273 out.push(format!(
274 "| `{}` | {} | {} | `{}` |",
275 MdCodeCell(&record.name),
276 record.ttl,
277 record.record_type,
278 MdCodeCell(&record.data.to_string())
279 ));
280 }
281}
282
283fn expiry(date: DateTime<Utc>, days_until: i64) -> String {
285 format!(
286 "`{}` ({})",
287 date.format("%Y-%m-%d"),
288 expiry_phrase(days_until)
289 )
290}
291
292struct Bullets<'a>(&'a mut Vec<String>);
296
297impl Bullets<'_> {
298 fn push(&mut self, line: String) {
300 self.0.push(line);
301 }
302
303 fn raw(&mut self, label: &str, value: impl fmt::Display) {
305 self.0.push(format!("- **{label}**: {value}"));
306 }
307
308 fn text(&mut self, label: &str, value: &str) {
310 self.raw(label, MdSafe(value));
311 }
312
313 fn opt(&mut self, label: &str, value: &Option<String>) {
315 if let Some(value) = value {
316 self.text(label, value);
317 }
318 }
319
320 fn code(&mut self, label: &str, value: &str) {
322 self.0.push(format!("- **{label}**: `{}`", MdCode(value)));
323 }
324
325 fn code_opt(&mut self, label: &str, value: &Option<String>) {
327 if let Some(value) = value {
328 self.code(label, value);
329 }
330 }
331
332 fn code_list(&mut self, label: &str, items: &[String]) {
334 if !items.is_empty() {
335 self.raw(label, code_list(items));
336 }
337 }
338
339 fn date(&mut self, label: &str, date: Option<DateTime<Utc>>) {
341 if let Some(date) = date {
342 self.0
343 .push(format!("- **{label}**: `{}`", date.format("%Y-%m-%d")));
344 }
345 }
346
347 fn expires(&mut self, date: Option<DateTime<Utc>>) {
350 if let Some(date) = date {
351 self.raw("Expires", expiry(date, days_until(date)));
352 }
353 }
354
355 fn contact(&mut self, role: &str, c: Contact<'_>) {
362 if c.is_empty() {
363 return;
364 }
365 self.0
366 .extend([String::new(), format!("### {role} Contact"), String::new()]);
367 for (label, field) in c.fields() {
368 match label {
369 "Email" => self.code_opt(label, field),
370 _ => self.opt(label, field),
371 }
372 }
373 }
374
375 fn contacts(&mut self, contacts: [Contact<'_>; 3]) {
377 for (role, c) in contact::ROLES.into_iter().zip(contacts) {
378 self.contact(role, c);
379 }
380 }
381}
382
383with_report_methods!(impl_forwarding!(MarkdownFormatter;));
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 fn md(s: &str) -> String {
392 format!("{}", MdSafe(s))
393 }
394
395 #[test]
396 fn test_mdsafe_strips_ansi_escape() {
397 assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
398 }
399
400 #[test]
401 fn test_mdsafe_collapses_newlines_cr_tab() {
402 assert_eq!(md("a\nb"), "a b");
403 assert_eq!(md("a\rb"), "a b");
404 assert_eq!(md("a\tb"), "a b");
405 assert_eq!(md("a\r\nb"), "a b");
408 }
409
410 #[test]
411 fn test_mdsafe_neutralizes_backticks() {
412 assert_eq!(md("`bad`"), "'bad'");
413 assert_eq!(md("a `b` c"), "a 'b' c");
414 }
415
416 #[test]
417 fn test_mdsafe_escapes_table_pipe() {
418 assert_eq!(md("a|b"), "a\\|b");
423 assert_eq!(md("x | y | z"), "x \\| y \\| z");
424 }
425
426 #[test]
427 fn test_mdsafe_drops_other_control_chars() {
428 assert_eq!(md("a\0b\x7fc"), "abc");
430 }
431
432 #[test]
433 fn test_mdsafe_preserves_unicode() {
434 assert_eq!(md("café — résumé"), "café — résumé");
435 }
436
437 #[test]
438 fn test_mdsafe_lone_esc_does_not_swallow_following_text() {
439 assert_eq!(md("\x1bXhello|world"), "Xhello\\|world");
443 }
444
445 #[test]
446 fn test_mdsafe_truncated_escape_does_not_swallow_newline_or_text() {
447 assert_eq!(md("\x1b[\nIgnore"), " Ignore");
451 }
452
453 #[test]
454 fn test_mdsafe_still_strips_wellformed_ansi() {
455 assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
457 assert_eq!(md("\x1b]0;title\x07rest"), "rest");
459 }
460
461 #[test]
462 fn test_mdsafe_esc_then_pipe_still_escapes_pipe() {
463 assert_eq!(md("\x1b|col"), "\\|col");
466 }
467
468 #[test]
469 fn test_mdsafe_neutralizes_links_images_and_html() {
470 assert_eq!(
474 md(""),
475 "!\\[x\\](https://attacker.example/p.png)"
476 );
477 assert_eq!(
478 md("[Verify](https://phish.example)"),
479 "\\[Verify\\](https://phish.example)"
480 );
481 assert_eq!(
482 md("<img src=https://attacker.example/p.png>"),
483 "\\<img src=https://attacker.example/p.png\\>"
484 );
485 assert_eq!(md("<https://phish.example>"), "\\<https://phish.example\\>");
486 assert_eq!(md("Example (Holdings) LLC"), "Example (Holdings) LLC");
488 }
489
490 #[test]
491 fn test_mdsafe_escapes_backslash_so_it_cannot_cancel_an_escape() {
492 assert_eq!(
496 md("\\[Verify\\](https://phish.example)"),
497 "\\\\\\[Verify\\\\\\](https://phish.example)"
498 );
499 assert_eq!(md("a\\|b"), "a\\\\\\|b");
501 assert_eq!(md("\\<img>"), "\\\\\\<img\\>");
502 assert_eq!(md("C:\\path"), "C:\\\\path");
503 }
504
505 fn code(s: &str) -> String {
506 format!("{}", MdCode(s))
507 }
508
509 fn bullet(write: impl FnOnce(&mut Bullets<'_>)) -> String {
511 let mut out = Vec::new();
512 write(&mut Bullets(&mut out));
513 assert_eq!(out.len(), 1, "{out:?}");
514 out.remove(0)
515 }
516
517 fn cell(s: &str) -> String {
518 format!("{}", MdCodeCell(s))
519 }
520
521 #[test]
522 fn code_spans_are_not_backslash_escaped() {
523 assert_eq!(code("CN=<x>"), "CN=<x>");
526 assert_eq!(code("[a](b) \\ c|d"), "[a](b) \\ c|d");
527 assert_eq!(code("a`b\nc\x1b[31m"), "a'b c");
529 }
530
531 #[test]
532 fn table_cell_code_spans_escape_pipes() {
533 assert_eq!(cell("a|b"), "a\\|b");
536 assert_eq!(cell("CN=<x>"), "CN=<x>");
537 assert_eq!(cell("a\\|b"), "a\\\\\\|b");
540 assert_eq!(cell("a\\b"), "a\\b");
541 assert_eq!(
542 bullet(|b| b.code("Subject", "CN=<x>")),
543 "- **Subject**: `CN=<x>`"
544 );
545 }
546
547 #[test]
548 fn invisible_bidi_and_zero_width_chars_are_dropped() {
549 let trojan = "a\u{202E}b\u{2066}c\u{200B}d\u{FEFF}e\u{061C}f\u{200F}g";
551 assert_eq!(md(trojan), "abcdefg");
552 assert_eq!(code(trojan), "abcdefg");
553 assert_eq!(cell(trojan), "abcdefg");
554 }
555
556 #[test]
557 fn contact_iterates_every_field_with_the_email_as_code() {
558 let (name, email, address) = (
559 Some("Jane".to_string()),
560 Some("jane@example.com".to_string()),
561 Some("1 Main St".to_string()),
562 );
563 let contact = Contact {
564 name: &name,
565 email: &email,
566 address: &address,
567 ..Contact::EMPTY
568 };
569 let mut out = Vec::new();
570 Bullets(&mut out).contact("Admin", contact);
571 assert_eq!(
572 out,
573 [
574 "",
575 "### Admin Contact",
576 "",
577 "- **Name**: Jane",
578 "- **Email**: `jane@example.com`",
579 "- **Address**: 1 Main St",
580 ]
581 );
582 }
583
584 #[test]
585 fn expires_bullet_says_expired_days_ago() {
586 let past = Utc::now() - chrono::Duration::days(3) - chrono::Duration::hours(1);
587 let line = bullet(|b| b.expires(Some(past)));
588 assert!(line.ends_with("(expired 3 days ago)"), "got: {line}");
589 let soon = Utc::now() + chrono::Duration::days(10) + chrono::Duration::hours(1);
590 let line = bullet(|b| b.expires(Some(soon)));
591 assert!(line.ends_with("(expires in 10 days)"), "got: {line}");
592 }
593
594 #[test]
595 fn domain_info_renders_registrar_detail_and_lifecycle_fields() {
596 let whois = WhoisResponse::parse(
600 "example.com",
601 "whois.test",
602 "Registrar: Example Registrar\n\
603 Creation Date: 2020-01-01T00:00:00Z\n\
604 Registry Expiry Date: 2099-01-01T00:00:00Z\n\
605 Domain Status: clientTransferProhibited\n",
606 );
607 let mut info =
608 crate::domain_info::DomainInfo::from_sources("example.com", None, Some(&whois));
609 info.registrar_abuse_email = Some("abuse@registrar.test".to_string());
610 info.registrar_abuse_phone = Some("+1.5555550100".to_string());
611 info.registrar_iana_id = Some("9999".to_string());
612 info.registrar_url = Some("https://registrar.test".to_string());
613
614 let out = MarkdownFormatter::new().format_domain_info(&info);
615 for needle in [
616 "Registrar Detail",
617 "abuse@registrar.test",
618 "+1.5555550100",
619 "9999",
620 "https://registrar.test",
621 "Days Until Expiry",
622 "Domain Age",
623 "Expiry Status",
624 "clientTransferProhibited",
625 ] {
626 assert!(out.contains(needle), "missing {needle:?} in:\n{out}");
627 }
628 }
629}