use std::fmt::{self, Write as _};
use chrono::{DateTime, Utc};
use super::OutputFormatter;
pub(super) use super::contact::{self, Contact, FlatContacts};
pub(super) use super::days_until;
pub(super) use super::grouping::render_grouped;
pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
pub(super) use crate::lookup::LookupResult;
pub(super) use crate::rdap::RdapResponse;
pub(super) use crate::status::StatusResponse;
pub(super) use crate::whois::WhoisResponse;
mod delegation;
mod diff;
mod dns;
mod domain_info;
mod lookup;
mod propagation;
mod rdap;
mod security;
mod status;
mod whois;
pub(super) struct MdSafe<'a>(pub &'a str);
impl fmt::Display for MdSafe<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut iter = self.0.chars().peekable();
while let Some(c) = iter.next() {
match c {
'\x1b' => consume_escape(&mut iter),
'\n' | '\r' | '\t' => f.write_str(" ")?,
'`' => f.write_str("'")?,
'|' => f.write_str("\\|")?,
'[' | ']' | '<' | '>' => {
f.write_char('\\')?;
f.write_char(c)?;
}
c if c.is_control() => {}
c => f.write_char(c)?,
}
}
Ok(())
}
}
const ANSI_SCAN_CAP: usize = 64;
fn consume_escape(iter: &mut std::iter::Peekable<std::str::Chars<'_>>) {
match iter.peek() {
Some('[') => {
iter.next(); for _ in 0..ANSI_SCAN_CAP {
match iter.peek() {
Some('\n') | Some('\r') | None => break,
Some(&ch) => {
iter.next();
if matches!(ch as u32, 0x40..=0x7E) {
break;
}
}
}
}
}
Some(']') | Some('P') => {
iter.next(); for _ in 0..ANSI_SCAN_CAP {
match iter.peek() {
Some('\n') | Some('\r') | None => break,
Some('\x07') => {
iter.next();
break;
}
Some('\x1b') => {
iter.next();
if iter.peek() == Some(&'\\') {
iter.next();
}
break;
}
Some(_) => {
iter.next();
}
}
}
}
Some('(') | Some(')') | Some('*') | Some('+') => {
iter.next(); if !matches!(iter.peek(), Some('\n') | Some('\r') | None) {
iter.next();
}
}
_ => {}
}
}
#[derive(Default)]
pub struct MarkdownFormatter;
impl MarkdownFormatter {
pub fn new() -> Self {
Self
}
fn render_caa_section(&self, caa: &CaaPolicy) -> Vec<String> {
let mut out = Vec::new();
out.push(String::new());
out.push("### CAA Policy".to_string());
out.push(String::new());
if !caa.has_policy {
out.push("*No CAA records (any CA may issue)*".to_string());
} else {
Bullets(&mut out).code_opt("Found at", &caa.effective_domain);
out.push(String::new());
out.push("| Flags | Tag | Value |".to_string());
out.push("| --- | --- | --- |".to_string());
for r in &caa.records {
out.push(format!(
"| {} | `{}` | `{}` |",
r.flags,
MdSafe(&r.tag),
MdSafe(&r.value)
));
}
}
if let Some(m) = caa.issuer_match {
let rendered = match m {
IssuerCaaMatch::NoPolicy => "no policy — any CA permitted",
IssuerCaaMatch::Permitted => "issuer permitted by current CAA policy",
IssuerCaaMatch::Mismatch => "issuer not in current CAA policy (informational)",
IssuerCaaMatch::Indeterminate => "CAA present but no issue/issuewild tags",
};
out.push(String::new());
out.push(format!("- **Issuer vs CAA**: {}", rendered));
}
out.push(String::new());
out.push(format!("> **Note:** {}", caa.note));
out
}
}
fn code_list<S: AsRef<str>>(items: &[S]) -> String {
items
.iter()
.map(|item| format!("`{}`", MdSafe(item.as_ref())))
.collect::<Vec<_>>()
.join(", ")
}
struct Bullets<'a>(&'a mut Vec<String>);
impl Bullets<'_> {
fn push(&mut self, line: String) {
self.0.push(line);
}
fn raw(&mut self, label: &str, value: impl fmt::Display) {
self.0.push(format!("- **{label}**: {value}"));
}
fn text(&mut self, label: &str, value: &str) {
self.raw(label, MdSafe(value));
}
fn opt(&mut self, label: &str, value: &Option<String>) {
if let Some(value) = value {
self.text(label, value);
}
}
fn code(&mut self, label: &str, value: &str) {
self.0.push(format!("- **{label}**: `{}`", MdSafe(value)));
}
fn code_opt(&mut self, label: &str, value: &Option<String>) {
if let Some(value) = value {
self.code(label, value);
}
}
fn code_list(&mut self, label: &str, items: &[String]) {
if !items.is_empty() {
self.raw(label, code_list(items));
}
}
fn date(&mut self, label: &str, date: Option<DateTime<Utc>>) {
if let Some(date) = date {
self.0
.push(format!("- **{label}**: `{}`", date.format("%Y-%m-%d")));
}
}
fn expires(&mut self, date: Option<DateTime<Utc>>) {
if let Some(date) = date {
self.0.push(format!(
"- **Expires**: `{}` ({} days)",
date.format("%Y-%m-%d"),
days_until(date)
));
}
}
fn contact(&mut self, role: &str, c: Contact<'_>) {
if c.is_empty() {
return;
}
self.0
.extend([String::new(), format!("### {role} Contact"), String::new()]);
self.opt("Name", c.name);
self.opt("Organization", c.organization);
self.code_opt("Email", c.email);
self.opt("Phone", c.phone);
self.opt("Address", c.address);
self.opt("Country", c.country);
}
fn contacts(&mut self, contacts: [Contact<'_>; 3]) {
for (role, c) in contact::ROLES.into_iter().zip(contacts) {
self.contact(role, c);
}
}
}
with_report_methods!(impl_forwarding!(MarkdownFormatter;));
#[cfg(test)]
mod tests {
use super::*;
fn md(s: &str) -> String {
format!("{}", MdSafe(s))
}
#[test]
fn test_mdsafe_strips_ansi_escape() {
assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
}
#[test]
fn test_mdsafe_collapses_newlines_cr_tab() {
assert_eq!(md("a\nb"), "a b");
assert_eq!(md("a\rb"), "a b");
assert_eq!(md("a\tb"), "a b");
assert_eq!(md("a\r\nb"), "a b");
}
#[test]
fn test_mdsafe_neutralizes_backticks() {
assert_eq!(md("`bad`"), "'bad'");
assert_eq!(md("a `b` c"), "a 'b' c");
}
#[test]
fn test_mdsafe_escapes_table_pipe() {
assert_eq!(md("a|b"), "a\\|b");
assert_eq!(md("x | y | z"), "x \\| y \\| z");
}
#[test]
fn test_mdsafe_drops_other_control_chars() {
assert_eq!(md("a\0b\x7fc"), "abc");
}
#[test]
fn test_mdsafe_preserves_unicode() {
assert_eq!(md("café — résumé"), "café — résumé");
}
#[test]
fn test_mdsafe_lone_esc_does_not_swallow_following_text() {
assert_eq!(md("\x1bXhello|world"), "Xhello\\|world");
}
#[test]
fn test_mdsafe_truncated_escape_does_not_swallow_newline_or_text() {
assert_eq!(md("\x1b[\nIgnore"), " Ignore");
}
#[test]
fn test_mdsafe_still_strips_wellformed_ansi() {
assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
assert_eq!(md("\x1b]0;title\x07rest"), "rest");
}
#[test]
fn test_mdsafe_esc_then_pipe_still_escapes_pipe() {
assert_eq!(md("\x1b|col"), "\\|col");
}
#[test]
fn test_mdsafe_neutralizes_links_images_and_html() {
assert_eq!(
md(""),
"!\\[x\\](https://attacker.example/p.png)"
);
assert_eq!(
md("[Verify](https://phish.example)"),
"\\[Verify\\](https://phish.example)"
);
assert_eq!(
md("<img src=https://attacker.example/p.png>"),
"\\<img src=https://attacker.example/p.png\\>"
);
assert_eq!(md("<https://phish.example>"), "\\<https://phish.example\\>");
assert_eq!(md("Example (Holdings) LLC"), "Example (Holdings) LLC");
}
#[test]
fn domain_info_renders_registrar_detail_and_lifecycle_fields() {
let whois = WhoisResponse::parse(
"example.com",
"whois.test",
"Registrar: Example Registrar\n\
Creation Date: 2020-01-01T00:00:00Z\n\
Registry Expiry Date: 2099-01-01T00:00:00Z\n\
Domain Status: clientTransferProhibited\n",
);
let mut info =
crate::domain_info::DomainInfo::from_sources("example.com", None, Some(&whois));
info.registrar_abuse_email = Some("abuse@registrar.test".to_string());
info.registrar_abuse_phone = Some("+1.5555550100".to_string());
info.registrar_iana_id = Some("9999".to_string());
info.registrar_url = Some("https://registrar.test".to_string());
let out = MarkdownFormatter::new().format_domain_info(&info);
for needle in [
"Registrar Detail",
"abuse@registrar.test",
"+1.5555550100",
"9999",
"https://registrar.test",
"Days Until Expiry",
"Domain Age",
"Expiry Status",
"clientTransferProhibited",
] {
assert!(out.contains(needle), "missing {needle:?} in:\n{out}");
}
}
}