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::grouping::render_grouped;
pub(super) use super::{
days_until, dnssec_depth, expiry_phrase, format_duration, propagation_detail,
propagation_difference, DNSSEC_NOTE, GEO_NOTE,
};
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 audit;
mod delegation;
mod diff;
mod dig;
mod dns;
mod domain_info;
mod lookup;
mod propagation;
mod rdap;
mod security;
mod status;
mod whois;
pub(super) fn heading(title: String) -> Vec<String> {
vec![title, String::new()]
}
pub(super) fn table_header(out: &mut Vec<String>, columns: &[&str]) {
out.push(format!("| {} |", columns.join(" | ")));
out.push(format!("|{}", " --- |".repeat(columns.len())));
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Context {
Text,
Code,
CodeCell,
}
fn write_escaped(f: &mut fmt::Formatter<'_>, text: &str, context: Context) -> fmt::Result {
let mut iter = text.chars().peekable();
while let Some(c) = iter.next() {
match c {
'\x1b' => consume_escape(&mut iter),
'\n' | '\r' | '\t' | '\u{2028}' | '\u{2029}' => f.write_str(" ")?,
'`' => f.write_str("'")?,
'\\' | '[' | ']' | '<' | '>' | '|' if context == Context::Text => {
f.write_char('\\')?;
f.write_char(c)?;
}
'|' if context == Context::CodeCell => f.write_str("\\|")?,
'\\' if context == Context::CodeCell && iter.peek() == Some(&'|') => {
f.write_str("\\\\")?;
}
c if c.is_control() || super::is_invisible_format(c) => {}
c => f.write_char(c)?,
}
}
Ok(())
}
pub(super) struct MdSafe<'a>(pub &'a str);
impl fmt::Display for MdSafe<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write_escaped(f, self.0, Context::Text)
}
}
pub(super) struct MdCode<'a>(pub &'a str);
impl fmt::Display for MdCode<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write_escaped(f, self.0, Context::Code)
}
}
pub(super) struct MdCodeCell<'a>(pub &'a str);
impl fmt::Display for MdCodeCell<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write_escaped(f, self.0, Context::CodeCell)
}
}
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![String::new(), "### CAA Policy".to_string(), String::new()];
caa_body(&mut out, caa);
out.extend([String::new(), format!("> **Note:** {}", caa.note)]);
out
}
}
fn caa_body(out: &mut Vec<String>, caa: &CaaPolicy) {
if !caa.has_policy {
out.push("*No CAA records (any CA may issue)*".to_string());
} else {
Bullets(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,
MdCodeCell(&r.tag),
MdCodeCell(&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());
Bullets(out).raw("Issuer vs CAA", rendered);
}
}
fn code_list<S: AsRef<str>>(items: &[S]) -> String {
items
.iter()
.map(|item| format!("`{}`", MdCode(item.as_ref())))
.collect::<Vec<_>>()
.join(", ")
}
fn code_cells<S: AsRef<str>>(items: &[S]) -> String {
items
.iter()
.map(|item| format!("`{}`", MdCodeCell(item.as_ref())))
.collect::<Vec<_>>()
.join(", ")
}
fn record_table<'r>(out: &mut Vec<String>, records: impl IntoIterator<Item = &'r DnsRecord>) {
out.push("| Name | TTL | Type | Data |".to_string());
out.push("| --- | --- | --- | --- |".to_string());
for record in records {
out.push(format!(
"| `{}` | {} | {} | `{}` |",
MdCodeCell(&record.name),
record.ttl,
record.record_type,
MdCodeCell(&record.data.to_string())
));
}
}
fn expiry(date: DateTime<Utc>, days_until: i64) -> String {
format!(
"`{}` ({})",
date.format("%Y-%m-%d"),
expiry_phrase(days_until)
)
}
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}**: `{}`", MdCode(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.raw("Expires", expiry(date, 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()]);
for (label, field) in c.fields() {
match label {
"Email" => self.code_opt(label, field),
_ => self.opt(label, field),
}
}
}
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 test_mdsafe_escapes_backslash_so_it_cannot_cancel_an_escape() {
assert_eq!(
md("\\[Verify\\](https://phish.example)"),
"\\\\\\[Verify\\\\\\](https://phish.example)"
);
assert_eq!(md("a\\|b"), "a\\\\\\|b");
assert_eq!(md("\\<img>"), "\\\\\\<img\\>");
assert_eq!(md("C:\\path"), "C:\\\\path");
}
fn code(s: &str) -> String {
format!("{}", MdCode(s))
}
fn bullet(write: impl FnOnce(&mut Bullets<'_>)) -> String {
let mut out = Vec::new();
write(&mut Bullets(&mut out));
assert_eq!(out.len(), 1, "{out:?}");
out.remove(0)
}
fn cell(s: &str) -> String {
format!("{}", MdCodeCell(s))
}
#[test]
fn code_spans_are_not_backslash_escaped() {
assert_eq!(code("CN=<x>"), "CN=<x>");
assert_eq!(code("[a](b) \\ c|d"), "[a](b) \\ c|d");
assert_eq!(code("a`b\nc\x1b[31m"), "a'b c");
}
#[test]
fn table_cell_code_spans_escape_pipes() {
assert_eq!(cell("a|b"), "a\\|b");
assert_eq!(cell("CN=<x>"), "CN=<x>");
assert_eq!(cell("a\\|b"), "a\\\\\\|b");
assert_eq!(cell("a\\b"), "a\\b");
assert_eq!(
bullet(|b| b.code("Subject", "CN=<x>")),
"- **Subject**: `CN=<x>`"
);
}
#[test]
fn invisible_bidi_and_zero_width_chars_are_dropped() {
let trojan = "a\u{202E}b\u{2066}c\u{200B}d\u{FEFF}e\u{061C}f\u{200F}g";
assert_eq!(md(trojan), "abcdefg");
assert_eq!(code(trojan), "abcdefg");
assert_eq!(cell(trojan), "abcdefg");
}
#[test]
fn contact_iterates_every_field_with_the_email_as_code() {
let (name, email, address) = (
Some("Jane".to_string()),
Some("jane@example.com".to_string()),
Some("1 Main St".to_string()),
);
let contact = Contact {
name: &name,
email: &email,
address: &address,
..Contact::EMPTY
};
let mut out = Vec::new();
Bullets(&mut out).contact("Admin", contact);
assert_eq!(
out,
[
"",
"### Admin Contact",
"",
"- **Name**: Jane",
"- **Email**: `jane@example.com`",
"- **Address**: 1 Main St",
]
);
}
#[test]
fn expires_bullet_says_expired_days_ago() {
let past = Utc::now() - chrono::Duration::days(3) - chrono::Duration::hours(1);
let line = bullet(|b| b.expires(Some(past)));
assert!(line.ends_with("(expired 3 days ago)"), "got: {line}");
let soon = Utc::now() + chrono::Duration::days(10) + chrono::Duration::hours(1);
let line = bullet(|b| b.expires(Some(soon)));
assert!(line.ends_with("(expires in 10 days)"), "got: {line}");
}
#[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}");
}
}
}