Skip to main content

seer_core/output/
markdown.rs

1use super::OutputFormatter;
2use crate::caa::{CaaPolicy, IssuerCaaMatch};
3use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
4use crate::lookup::LookupResult;
5use crate::rdap::RdapResponse;
6use crate::status::StatusResponse;
7use crate::whois::WhoisResponse;
8use std::fmt::{self, Write as _};
9
10/// `Display` adapter that renders attacker-controlled WHOIS/RDAP/DNS/SSL
11/// strings safely inside Markdown that will be forwarded to an LLM (via
12/// the MCP server). Strips ANSI escape sequences and ASCII control
13/// characters, collapses newlines/CR/tabs to spaces (so attacker text
14/// cannot break out of a table row or look like a new heading), and
15/// neutralizes backticks (so an attacker can't terminate a code span and
16/// inject Markdown structure).
17pub(super) struct MdSafe<'a>(pub &'a str);
18
19impl fmt::Display for MdSafe<'_> {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        let mut iter = self.0.chars();
22        while let Some(c) = iter.next() {
23            match c {
24                '\x1b' => {
25                    // Consume the rest of the ANSI escape sequence (CSI or
26                    // OSC). The byte right after ESC is the introducer
27                    // (`[` for CSI, `]` for OSC, etc.) — skip it
28                    // unconditionally so it isn't treated as a terminator.
29                    // Then look for the terminator: CSI ends on a byte in
30                    // `@`-`~`; OSC ends on BEL (0x07) or ST. Cap
31                    // consumption defensively.
32                    let _ = iter.next();
33                    for inner in iter.by_ref().take(64) {
34                        if matches!(inner as u32, 0x40..=0x7E) || inner == '\x07' {
35                            break;
36                        }
37                    }
38                }
39                '\n' | '\r' | '\t' => f.write_str(" ")?,
40                '`' => f.write_str("'")?,
41                c if c.is_control() => {}
42                c => f.write_char(c)?,
43            }
44        }
45        Ok(())
46    }
47}
48
49/// Markdown output formatter that produces clean, readable Markdown.
50pub struct MarkdownFormatter;
51
52impl Default for MarkdownFormatter {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl MarkdownFormatter {
59    pub fn new() -> Self {
60        Self
61    }
62
63    /// Renders the CAA policy as a Markdown section shared between SSL and
64    /// status reports.
65    fn render_caa_section(&self, caa: &CaaPolicy) -> Vec<String> {
66        let mut out = Vec::new();
67        out.push(String::new());
68        out.push("### CAA Policy".to_string());
69        out.push(String::new());
70
71        if !caa.has_policy {
72            out.push("*No CAA records (any CA may issue)*".to_string());
73        } else {
74            if let Some(ref eff) = caa.effective_domain {
75                out.push(format!("- **Found at**: `{}`", MdSafe(eff)));
76            }
77            out.push(String::new());
78            out.push("| Flags | Tag | Value |".to_string());
79            out.push("| --- | --- | --- |".to_string());
80            for r in &caa.records {
81                out.push(format!(
82                    "| {} | `{}` | `{}` |",
83                    r.flags,
84                    MdSafe(&r.tag),
85                    MdSafe(&r.value)
86                ));
87            }
88        }
89
90        if let Some(m) = caa.issuer_match {
91            let rendered = match m {
92                IssuerCaaMatch::NoPolicy => "no policy — any CA permitted",
93                IssuerCaaMatch::Permitted => "issuer permitted by current CAA policy",
94                IssuerCaaMatch::Mismatch => "issuer not in current CAA policy (informational)",
95                IssuerCaaMatch::Indeterminate => "CAA present but no issue/issuewild tags",
96            };
97            out.push(String::new());
98            out.push(format!("- **Issuer vs CAA**: {}", rendered));
99        }
100
101        out.push(String::new());
102        out.push(format!("> **Note:** {}", caa.note));
103        out
104    }
105
106    /// Formats a contact section for RDAP entities.
107    fn format_rdap_contact(
108        &self,
109        output: &mut Vec<String>,
110        label: &str,
111        contact: &crate::rdap::ContactInfo,
112    ) {
113        if !contact.has_info() {
114            return;
115        }
116        output.push(String::new());
117        output.push(format!("### {}", label));
118        output.push(String::new());
119        if let Some(ref name) = contact.name {
120            output.push(format!("- **Name**: {}", MdSafe(name)));
121        }
122        if let Some(ref org) = contact.organization {
123            output.push(format!("- **Organization**: {}", MdSafe(org)));
124        }
125        if let Some(ref email) = contact.email {
126            output.push(format!("- **Email**: `{}`", MdSafe(email)));
127        }
128        if let Some(ref phone) = contact.phone {
129            output.push(format!("- **Phone**: {}", MdSafe(phone)));
130        }
131        if let Some(ref address) = contact.address {
132            output.push(format!("- **Address**: {}", MdSafe(address)));
133        }
134        if let Some(ref country) = contact.country {
135            output.push(format!("- **Country**: {}", MdSafe(country)));
136        }
137    }
138
139    /// Formats WHOIS contact fields as a markdown subsection.
140    fn format_whois_contact(
141        &self,
142        output: &mut Vec<String>,
143        label: &str,
144        name: &Option<String>,
145        organization: &Option<String>,
146        email: &Option<String>,
147        phone: &Option<String>,
148    ) {
149        let has_info =
150            name.is_some() || organization.is_some() || email.is_some() || phone.is_some();
151        if !has_info {
152            return;
153        }
154        output.push(String::new());
155        output.push(format!("### {}", label));
156        output.push(String::new());
157        if let Some(ref v) = *name {
158            output.push(format!("- **Name**: {}", MdSafe(v)));
159        }
160        if let Some(ref v) = *organization {
161            output.push(format!("- **Organization**: {}", MdSafe(v)));
162        }
163        if let Some(ref v) = *email {
164            output.push(format!("- **Email**: `{}`", MdSafe(v)));
165        }
166        if let Some(ref v) = *phone {
167            output.push(format!("- **Phone**: {}", MdSafe(v)));
168        }
169    }
170}
171
172impl OutputFormatter for MarkdownFormatter {
173    fn format_whois(&self, response: &WhoisResponse) -> String {
174        let mut output = Vec::new();
175
176        output.push(format!("## WHOIS: {}", MdSafe(&response.domain)));
177        output.push(String::new());
178
179        if response.is_available() {
180            output.push("Domain is **available** for registration.".to_string());
181            return output.join("\n");
182        }
183
184        if let Some(ref registrar) = response.registrar {
185            output.push(format!("- **Registrar**: {}", MdSafe(registrar)));
186        }
187        if let Some(ref registrant) = response.registrant {
188            output.push(format!("- **Registrant**: {}", MdSafe(registrant)));
189        }
190        if let Some(ref organization) = response.organization {
191            output.push(format!("- **Organization**: {}", MdSafe(organization)));
192        }
193
194        // Registrant contact details
195        let has_registrant_details = response.registrant_email.is_some()
196            || response.registrant_phone.is_some()
197            || response.registrant_address.is_some()
198            || response.registrant_country.is_some();
199
200        if has_registrant_details {
201            output.push(String::new());
202            output.push("### Registrant Contact".to_string());
203            output.push(String::new());
204            if let Some(ref email) = response.registrant_email {
205                output.push(format!("- **Email**: `{}`", MdSafe(email)));
206            }
207            if let Some(ref phone) = response.registrant_phone {
208                output.push(format!("- **Phone**: {}", MdSafe(phone)));
209            }
210            if let Some(ref address) = response.registrant_address {
211                output.push(format!("- **Address**: {}", MdSafe(address)));
212            }
213            if let Some(ref country) = response.registrant_country {
214                output.push(format!("- **Country**: {}", MdSafe(country)));
215            }
216        }
217
218        // Admin contact
219        self.format_whois_contact(
220            &mut output,
221            "Admin Contact",
222            &response.admin_name,
223            &response.admin_organization,
224            &response.admin_email,
225            &response.admin_phone,
226        );
227
228        // Tech contact
229        self.format_whois_contact(
230            &mut output,
231            "Tech Contact",
232            &response.tech_name,
233            &response.tech_organization,
234            &response.tech_email,
235            &response.tech_phone,
236        );
237
238        if let Some(created) = response.creation_date {
239            output.push(format!("- **Created**: `{}`", created.format("%Y-%m-%d")));
240        }
241        if let Some(expires) = response.expiration_date {
242            let days_until = (expires - chrono::Utc::now()).num_days();
243            output.push(format!(
244                "- **Expires**: `{}` ({} days)",
245                expires.format("%Y-%m-%d"),
246                days_until
247            ));
248        }
249        if let Some(updated) = response.updated_date {
250            output.push(format!("- **Updated**: `{}`", updated.format("%Y-%m-%d")));
251        }
252
253        if !response.nameservers.is_empty() {
254            output.push(format!(
255                "- **Nameservers**: {}",
256                response
257                    .nameservers
258                    .iter()
259                    .map(|ns| format!("`{}`", MdSafe(ns)))
260                    .collect::<Vec<_>>()
261                    .join(", ")
262            ));
263        }
264
265        if !response.status.is_empty() {
266            output.push(format!(
267                "- **Status**: {}",
268                response
269                    .status
270                    .iter()
271                    .map(|s| format!("`{}`", MdSafe(s)))
272                    .collect::<Vec<_>>()
273                    .join(", ")
274            ));
275        }
276
277        if let Some(ref dnssec) = response.dnssec {
278            output.push(format!("- **DNSSEC**: {}", MdSafe(dnssec)));
279        }
280
281        output.push(format!(
282            "- **WHOIS Server**: `{}`",
283            MdSafe(&response.whois_server)
284        ));
285
286        output.join("\n")
287    }
288
289    fn format_rdap(&self, response: &RdapResponse) -> String {
290        let mut output = Vec::new();
291
292        let name = response
293            .domain_name()
294            .or(response.name.as_deref())
295            .unwrap_or("Unknown");
296        output.push(format!("## RDAP: {}", MdSafe(name)));
297        output.push(String::new());
298
299        if let Some(ref handle) = response.handle {
300            output.push(format!("- **Handle**: `{}`", MdSafe(handle)));
301        }
302        if let Some(registrar) = response.get_registrar() {
303            output.push(format!("- **Registrar**: {}", MdSafe(&registrar)));
304        }
305        if let Some(registrant) = response.get_registrant() {
306            output.push(format!("- **Registrant**: {}", MdSafe(&registrant)));
307        }
308        if let Some(organization) = response.get_registrant_organization() {
309            output.push(format!("- **Organization**: {}", MdSafe(&organization)));
310        }
311
312        // Contact sections
313        if let Some(contact) = response.get_registrant_contact() {
314            self.format_rdap_contact(&mut output, "Registrant Contact", &contact);
315        }
316        if let Some(contact) = response.get_admin_contact() {
317            self.format_rdap_contact(&mut output, "Admin Contact", &contact);
318        }
319        if let Some(contact) = response.get_tech_contact() {
320            self.format_rdap_contact(&mut output, "Tech Contact", &contact);
321        }
322        if let Some(contact) = response.get_billing_contact() {
323            self.format_rdap_contact(&mut output, "Billing Contact", &contact);
324        }
325
326        if let Some(created) = response.creation_date() {
327            output.push(format!("- **Created**: `{}`", created.format("%Y-%m-%d")));
328        }
329        if let Some(expires) = response.expiration_date() {
330            let days_until = (expires - chrono::Utc::now()).num_days();
331            output.push(format!(
332                "- **Expires**: `{}` ({} days)",
333                expires.format("%Y-%m-%d"),
334                days_until
335            ));
336        }
337        if let Some(updated) = response.last_updated() {
338            output.push(format!("- **Updated**: `{}`", updated.format("%Y-%m-%d")));
339        }
340
341        if !response.status.is_empty() {
342            output.push(format!(
343                "- **Status**: {}",
344                response
345                    .status
346                    .iter()
347                    .map(|s| format!("`{}`", MdSafe(s)))
348                    .collect::<Vec<_>>()
349                    .join(", ")
350            ));
351        }
352
353        let nameservers = response.nameserver_names();
354        if !nameservers.is_empty() {
355            output.push(format!(
356                "- **Nameservers**: {}",
357                nameservers
358                    .iter()
359                    .map(|ns| format!("`{}`", MdSafe(ns)))
360                    .collect::<Vec<_>>()
361                    .join(", ")
362            ));
363        }
364
365        if response.is_dnssec_signed() {
366            output.push("- **DNSSEC**: signed".to_string());
367        }
368
369        // IP-specific fields
370        if let Some(ref start) = response.start_address {
371            output.push(format!("- **Start Address**: `{}`", MdSafe(start)));
372        }
373        if let Some(ref end) = response.end_address {
374            output.push(format!("- **End Address**: `{}`", MdSafe(end)));
375        }
376        if let Some(ref country) = response.country {
377            output.push(format!("- **Country**: {}", MdSafe(country)));
378        }
379
380        // ASN-specific fields
381        if let Some(start) = response.start_autnum {
382            output.push(format!(
383                "- **AS Number**: `AS{}` - `AS{}`",
384                start,
385                response.end_autnum.unwrap_or(start)
386            ));
387        }
388
389        output.join("\n")
390    }
391
392    fn format_dns(&self, records: &[DnsRecord]) -> String {
393        let mut output = Vec::new();
394
395        if records.is_empty() {
396            output.push("*No records found*".to_string());
397            output.push(String::new());
398            output.push("> Note: DNS responses are not DNSSEC-validated.".to_string());
399            return output.join("\n");
400        }
401
402        let domain = &records[0].name;
403        let record_type = &records[0].record_type;
404        output.push(format!(
405            "## DNS {} Records: {}",
406            record_type,
407            MdSafe(domain)
408        ));
409        output.push(String::new());
410        output.push("| Name | TTL | Type | Data |".to_string());
411        output.push("| --- | --- | --- | --- |".to_string());
412
413        for record in records {
414            let data_str = record.data.to_string();
415            output.push(format!(
416                "| `{}` | {} | {} | `{}` |",
417                MdSafe(&record.name),
418                record.ttl,
419                record.record_type,
420                MdSafe(&data_str)
421            ));
422        }
423
424        output.push(String::new());
425        output.push("> Note: DNS responses are not DNSSEC-validated.".to_string());
426
427        output.join("\n")
428    }
429
430    fn format_propagation(&self, result: &PropagationResult) -> String {
431        let mut output = Vec::new();
432
433        output.push(format!(
434            "## Propagation: {} {}",
435            MdSafe(&result.domain),
436            result.record_type
437        ));
438        output.push(String::new());
439
440        // Summary
441        let percentage = result.propagation_percentage;
442        let status = if percentage >= 100.0 {
443            "Fully propagated"
444        } else if percentage >= 80.0 {
445            "Mostly propagated"
446        } else if percentage >= 50.0 {
447            "Partially propagated"
448        } else {
449            "Not propagated"
450        };
451        output.push(format!("**{:.1}%** - {}", percentage, status));
452        output.push(String::new());
453        output.push(format!(
454            "- **Servers responding**: {}/{}",
455            result.servers_responding, result.servers_checked
456        ));
457
458        // Consensus values
459        if !result.consensus_values.is_empty() {
460            output.push(format!(
461                "- **Consensus values**: {}",
462                result
463                    .consensus_values
464                    .iter()
465                    .map(|v| format!("`{}`", MdSafe(v)))
466                    .collect::<Vec<_>>()
467                    .join(", ")
468            ));
469        }
470
471        // Inconsistencies (genuine answer conflicts)
472        if !result.inconsistencies.is_empty() {
473            output.push(String::new());
474            output.push("### Inconsistencies".to_string());
475            output.push(String::new());
476            for inconsistency in &result.inconsistencies {
477                output.push(format!("- {}", MdSafe(inconsistency)));
478            }
479        }
480
481        // Unreachable servers (distinct from answer conflicts)
482        if !result.unreachable_servers.is_empty() {
483            output.push(String::new());
484            output.push("### Unreachable servers".to_string());
485            output.push(String::new());
486            for unreachable in &result.unreachable_servers {
487                let error_msg = unreachable.error.as_deref().unwrap_or("no response");
488                output.push(format!(
489                    "- **{}** (`{}`): {}",
490                    unreachable.name,
491                    unreachable.ip,
492                    MdSafe(error_msg)
493                ));
494            }
495        }
496
497        // Results table
498        output.push(String::new());
499        output.push("### Results".to_string());
500        output.push(String::new());
501        output.push("| Server | Location | IP | Result | Time |".to_string());
502        output.push("| --- | --- | --- | --- | --- |".to_string());
503
504        for sr in &result.results {
505            let result_str = if sr.success {
506                if sr.records.is_empty() {
507                    "NXDOMAIN".to_string()
508                } else {
509                    sr.records
510                        .iter()
511                        .map(|r| r.format_short())
512                        .collect::<Vec<_>>()
513                        .join(", ")
514                }
515            } else {
516                sr.error.as_deref().unwrap_or("Error").to_string()
517            };
518
519            output.push(format!(
520                "| {} | {} | `{}` | `{}` | {}ms |",
521                sr.server.name,
522                sr.server.location,
523                sr.server.ip,
524                MdSafe(&result_str),
525                sr.response_time_ms
526            ));
527        }
528
529        if !result.dnssec_validated {
530            output.push(String::new());
531            output.push("> Note: DNS responses are not DNSSEC-validated.".to_string());
532        }
533
534        output.join("\n")
535    }
536
537    fn format_lookup(&self, result: &LookupResult) -> String {
538        let mut output = Vec::new();
539
540        let domain = result
541            .domain_name()
542            .unwrap_or_else(|| "Unknown".to_string());
543        let source = match result {
544            LookupResult::Rdap { .. } => "RDAP",
545            LookupResult::Whois { .. } => "WHOIS",
546            LookupResult::Available { .. } => "availability",
547        };
548
549        output.push(format!("## Lookup: {}", MdSafe(&domain)));
550        output.push(String::new());
551        output.push(format!("- **Source**: {}", source));
552
553        match result {
554            LookupResult::Rdap {
555                data,
556                whois_fallback,
557            } => {
558                if let Some(registrar) = data.get_registrar() {
559                    output.push(format!("- **Registrar**: {}", MdSafe(&registrar)));
560                }
561                if let Some(registrant) = data.get_registrant() {
562                    output.push(format!("- **Registrant**: {}", MdSafe(&registrant)));
563                }
564                if let Some(organization) = data.get_registrant_organization() {
565                    output.push(format!("- **Organization**: {}", MdSafe(&organization)));
566                }
567
568                // Contact sections from RDAP
569                if let Some(contact) = data.get_registrant_contact() {
570                    self.format_rdap_contact(&mut output, "Registrant Contact", &contact);
571                }
572                if let Some(contact) = data.get_admin_contact() {
573                    self.format_rdap_contact(&mut output, "Admin Contact", &contact);
574                }
575                if let Some(contact) = data.get_tech_contact() {
576                    self.format_rdap_contact(&mut output, "Tech Contact", &contact);
577                }
578
579                if let Some(created) = data.creation_date() {
580                    output.push(format!("- **Created**: `{}`", created.format("%Y-%m-%d")));
581                }
582                if let Some(expires) = data.expiration_date() {
583                    let days_until = (expires - chrono::Utc::now()).num_days();
584                    output.push(format!(
585                        "- **Expires**: `{}` ({} days)",
586                        expires.format("%Y-%m-%d"),
587                        days_until
588                    ));
589                }
590
591                if !data.status.is_empty() {
592                    output.push(format!(
593                        "- **Status**: {}",
594                        data.status
595                            .iter()
596                            .map(|s| format!("`{}`", MdSafe(s)))
597                            .collect::<Vec<_>>()
598                            .join(", ")
599                    ));
600                }
601
602                let nameservers = data.nameserver_names();
603                if !nameservers.is_empty() {
604                    output.push(format!(
605                        "- **Nameservers**: {}",
606                        nameservers
607                            .iter()
608                            .map(|ns| format!("`{}`", MdSafe(ns)))
609                            .collect::<Vec<_>>()
610                            .join(", ")
611                    ));
612                }
613
614                if data.is_dnssec_signed() {
615                    output.push("- **DNSSEC**: signed".to_string());
616                }
617
618                // WHOIS fallback data
619                if let Some(whois) = whois_fallback {
620                    let mut extra = Vec::new();
621
622                    if data.get_registrant().is_none() {
623                        if let Some(ref registrant) = whois.registrant {
624                            extra.push(format!("- **Registrant**: {}", MdSafe(registrant)));
625                        }
626                    }
627                    if data.get_registrant_organization().is_none() {
628                        if let Some(ref org) = whois.organization {
629                            extra.push(format!("- **Organization**: {}", MdSafe(org)));
630                        }
631                    }
632
633                    // Registrant contact from WHOIS fallback
634                    let rdap_has_registrant = data
635                        .get_registrant_contact()
636                        .as_ref()
637                        .is_some_and(|c| c.has_info());
638                    if !rdap_has_registrant {
639                        let has_whois_contact = whois.registrant_email.is_some()
640                            || whois.registrant_phone.is_some()
641                            || whois.registrant_address.is_some()
642                            || whois.registrant_country.is_some();
643                        if has_whois_contact {
644                            extra.push(String::new());
645                            extra.push("### Registrant Contact".to_string());
646                            extra.push(String::new());
647                            if let Some(ref email) = whois.registrant_email {
648                                extra.push(format!("- **Email**: `{}`", MdSafe(email)));
649                            }
650                            if let Some(ref phone) = whois.registrant_phone {
651                                extra.push(format!("- **Phone**: {}", MdSafe(phone)));
652                            }
653                            if let Some(ref address) = whois.registrant_address {
654                                extra.push(format!("- **Address**: {}", MdSafe(address)));
655                            }
656                            if let Some(ref country) = whois.registrant_country {
657                                extra.push(format!("- **Country**: {}", MdSafe(country)));
658                            }
659                        }
660                    }
661
662                    // Admin contact from WHOIS fallback
663                    let rdap_has_admin = data.get_admin_contact().is_some_and(|c| c.has_info());
664                    if !rdap_has_admin {
665                        let has_whois_admin = whois.admin_name.is_some()
666                            || whois.admin_email.is_some()
667                            || whois.admin_phone.is_some();
668                        if has_whois_admin {
669                            extra.push(String::new());
670                            extra.push("### Admin Contact".to_string());
671                            extra.push(String::new());
672                            if let Some(ref name) = whois.admin_name {
673                                extra.push(format!("- **Name**: {}", MdSafe(name)));
674                            }
675                            if let Some(ref org) = whois.admin_organization {
676                                extra.push(format!("- **Organization**: {}", MdSafe(org)));
677                            }
678                            if let Some(ref email) = whois.admin_email {
679                                extra.push(format!("- **Email**: `{}`", MdSafe(email)));
680                            }
681                            if let Some(ref phone) = whois.admin_phone {
682                                extra.push(format!("- **Phone**: {}", MdSafe(phone)));
683                            }
684                        }
685                    }
686
687                    // Tech contact from WHOIS fallback
688                    let rdap_has_tech = data.get_tech_contact().is_some_and(|c| c.has_info());
689                    if !rdap_has_tech {
690                        let has_whois_tech = whois.tech_name.is_some()
691                            || whois.tech_email.is_some()
692                            || whois.tech_phone.is_some();
693                        if has_whois_tech {
694                            extra.push(String::new());
695                            extra.push("### Tech Contact".to_string());
696                            extra.push(String::new());
697                            if let Some(ref name) = whois.tech_name {
698                                extra.push(format!("- **Name**: {}", MdSafe(name)));
699                            }
700                            if let Some(ref org) = whois.tech_organization {
701                                extra.push(format!("- **Organization**: {}", MdSafe(org)));
702                            }
703                            if let Some(ref email) = whois.tech_email {
704                                extra.push(format!("- **Email**: `{}`", MdSafe(email)));
705                            }
706                            if let Some(ref phone) = whois.tech_phone {
707                                extra.push(format!("- **Phone**: {}", MdSafe(phone)));
708                            }
709                        }
710                    }
711
712                    if let Some(updated) = whois.updated_date {
713                        extra.push(format!("- **Updated**: `{}`", updated.format("%Y-%m-%d")));
714                    }
715
716                    if !data.is_dnssec_signed() {
717                        if let Some(ref dnssec) = whois.dnssec {
718                            extra.push(format!("- **DNSSEC**: {}", MdSafe(dnssec)));
719                        }
720                    }
721
722                    if !whois.whois_server.is_empty() {
723                        extra.push(format!(
724                            "- **WHOIS Server**: `{}`",
725                            MdSafe(&whois.whois_server)
726                        ));
727                    }
728
729                    if !extra.is_empty() {
730                        output.push(String::new());
731                        output.push("### Additional WHOIS Data".to_string());
732                        output.push(String::new());
733                        output.extend(extra);
734                    }
735                }
736            }
737            LookupResult::Whois {
738                data, rdap_error, ..
739            } => {
740                if let Some(ref error) = rdap_error {
741                    output.push(format!("- **RDAP Error**: {}", MdSafe(error)));
742                }
743
744                if let Some(ref registrar) = data.registrar {
745                    output.push(format!("- **Registrar**: {}", MdSafe(registrar)));
746                }
747                if let Some(ref registrant) = data.registrant {
748                    output.push(format!("- **Registrant**: {}", MdSafe(registrant)));
749                }
750                if let Some(ref organization) = data.organization {
751                    output.push(format!("- **Organization**: {}", MdSafe(organization)));
752                }
753
754                // Registrant contact details
755                let has_registrant_details = data.registrant_email.is_some()
756                    || data.registrant_phone.is_some()
757                    || data.registrant_address.is_some()
758                    || data.registrant_country.is_some();
759
760                if has_registrant_details {
761                    output.push(String::new());
762                    output.push("### Registrant Contact".to_string());
763                    output.push(String::new());
764                    if let Some(ref email) = data.registrant_email {
765                        output.push(format!("- **Email**: `{}`", MdSafe(email)));
766                    }
767                    if let Some(ref phone) = data.registrant_phone {
768                        output.push(format!("- **Phone**: {}", MdSafe(phone)));
769                    }
770                    if let Some(ref address) = data.registrant_address {
771                        output.push(format!("- **Address**: {}", MdSafe(address)));
772                    }
773                    if let Some(ref country) = data.registrant_country {
774                        output.push(format!("- **Country**: {}", MdSafe(country)));
775                    }
776                }
777
778                // Admin contact
779                self.format_whois_contact(
780                    &mut output,
781                    "Admin Contact",
782                    &data.admin_name,
783                    &data.admin_organization,
784                    &data.admin_email,
785                    &data.admin_phone,
786                );
787
788                // Tech contact
789                self.format_whois_contact(
790                    &mut output,
791                    "Tech Contact",
792                    &data.tech_name,
793                    &data.tech_organization,
794                    &data.tech_email,
795                    &data.tech_phone,
796                );
797
798                if let Some(created) = data.creation_date {
799                    output.push(format!("- **Created**: `{}`", created.format("%Y-%m-%d")));
800                }
801                if let Some(expires) = data.expiration_date {
802                    let days_until = (expires - chrono::Utc::now()).num_days();
803                    output.push(format!(
804                        "- **Expires**: `{}` ({} days)",
805                        expires.format("%Y-%m-%d"),
806                        days_until
807                    ));
808                }
809
810                if !data.status.is_empty() {
811                    output.push(format!(
812                        "- **Status**: {}",
813                        data.status
814                            .iter()
815                            .map(|s| format!("`{}`", MdSafe(s)))
816                            .collect::<Vec<_>>()
817                            .join(", ")
818                    ));
819                }
820
821                if !data.nameservers.is_empty() {
822                    output.push(format!(
823                        "- **Nameservers**: {}",
824                        data.nameservers
825                            .iter()
826                            .map(|ns| format!("`{}`", MdSafe(ns)))
827                            .collect::<Vec<_>>()
828                            .join(", ")
829                    ));
830                }
831
832                if let Some(ref dnssec) = data.dnssec {
833                    output.push(format!("- **DNSSEC**: {}", MdSafe(dnssec)));
834                }
835            }
836            LookupResult::Available {
837                data,
838                rdap_error,
839                whois_error,
840                whois_data,
841            } => {
842                let verdict = match data.confidence.as_str() {
843                    "high" => "AVAILABLE",
844                    "medium" => "MAY BE AVAILABLE",
845                    _ => "UNKNOWN",
846                };
847                output.push(format!("- **Verdict**: {}", verdict));
848                output.push(format!("- **Confidence**: {}", data.confidence));
849                output.push(format!("- **Method**: {}", data.method));
850                if let Some(ref details) = data.details {
851                    output.push(format!("- **Details**: {}", MdSafe(details)));
852                }
853                if !rdap_error.is_empty() {
854                    output.push(format!("- **RDAP Error**: {}", MdSafe(rdap_error)));
855                }
856                if !whois_error.is_empty() {
857                    output.push(format!("- **WHOIS Error**: {}", MdSafe(whois_error)));
858                }
859
860                if let Some(w) = whois_data {
861                    let mut bullets = Vec::new();
862                    if !w.nameservers.is_empty() {
863                        bullets.push(format!(
864                            "- **Nameservers**: {}",
865                            w.nameservers
866                                .iter()
867                                .map(|ns| format!("`{}`", MdSafe(ns)))
868                                .collect::<Vec<_>>()
869                                .join(", ")
870                        ));
871                    }
872                    if !w.status.is_empty() {
873                        bullets.push(format!(
874                            "- **Status**: {}",
875                            w.status
876                                .iter()
877                                .map(|s| format!("`{}`", MdSafe(s)))
878                                .collect::<Vec<_>>()
879                                .join(", ")
880                        ));
881                    }
882                    if let Some(ref dnssec) = w.dnssec {
883                        bullets.push(format!("- **DNSSEC**: {}", MdSafe(dnssec)));
884                    }
885                    if !w.whois_server.is_empty() {
886                        bullets.push(format!("- **WHOIS Server**: `{}`", MdSafe(&w.whois_server)));
887                    }
888                    if !bullets.is_empty() {
889                        output.push(String::new());
890                        output.push("### Additional WHOIS data".to_string());
891                        output.push(String::new());
892                        output.extend(bullets);
893                    }
894                }
895            }
896        }
897
898        output.join("\n")
899    }
900
901    fn format_status(&self, response: &StatusResponse) -> String {
902        let mut output = Vec::new();
903
904        output.push(format!("## Status: {}", MdSafe(&response.domain)));
905        output.push(String::new());
906
907        // HTTP Status
908        if let Some(status) = response.http_status {
909            let status_text = response.http_status_text.as_deref().unwrap_or("Unknown");
910            output.push(format!(
911                "- **HTTP Status**: `{}` ({})",
912                status,
913                MdSafe(status_text)
914            ));
915        }
916
917        // Site Title
918        if let Some(ref title) = response.title {
919            output.push(format!("- **Site Title**: {}", MdSafe(title)));
920        }
921
922        // SSL Certificate
923        output.push(String::new());
924        if let Some(ref cert) = response.certificate {
925            output.push("### SSL Certificate".to_string());
926            output.push(String::new());
927            output.push(format!("- **Subject**: `{}`", MdSafe(&cert.subject)));
928            output.push(format!("- **Issuer**: {}", MdSafe(&cert.issuer)));
929            output.push(format!(
930                "- **Status**: {}",
931                if cert.is_valid { "Valid" } else { "Invalid" }
932            ));
933            output.push(format!(
934                "- **Valid From**: `{}`",
935                cert.valid_from.format("%Y-%m-%d")
936            ));
937            output.push(format!(
938                "- **Expires**: `{}` ({} days)",
939                cert.valid_until.format("%Y-%m-%d"),
940                cert.days_until_expiry
941            ));
942        } else {
943            output.push("### SSL Certificate".to_string());
944            output.push(String::new());
945            output.push("*Not available (HTTPS may not be configured)*".to_string());
946        }
947
948        if let Some(ref caa) = response.caa {
949            output.extend(self.render_caa_section(caa));
950        }
951
952        // Domain Expiration
953        if let Some(ref expiry) = response.domain_expiration {
954            output.push(String::new());
955            output.push("### Domain Registration".to_string());
956            output.push(String::new());
957            if let Some(ref registrar) = expiry.registrar {
958                output.push(format!("- **Registrar**: {}", MdSafe(registrar)));
959            }
960            output.push(format!(
961                "- **Expires**: `{}` ({} days)",
962                expiry.expiration_date.format("%Y-%m-%d"),
963                expiry.days_until_expiry
964            ));
965        }
966
967        // DNS Resolution
968        output.push(String::new());
969        if let Some(ref dns) = response.dns_resolution {
970            output.push("### DNS Resolution".to_string());
971            output.push(String::new());
972            output.push(format!(
973                "- **Resolves**: {}",
974                if dns.resolves { "Yes" } else { "No" }
975            ));
976
977            if let Some(ref cname) = dns.cname_target {
978                output.push(format!("- **CNAME**: `{}`", MdSafe(cname)));
979            }
980            if !dns.a_records.is_empty() {
981                output.push(format!(
982                    "- **IPv4 (A)**: {}",
983                    dns.a_records
984                        .iter()
985                        .map(|ip| format!("`{}`", MdSafe(ip)))
986                        .collect::<Vec<_>>()
987                        .join(", ")
988                ));
989            }
990            if !dns.aaaa_records.is_empty() {
991                output.push(format!(
992                    "- **IPv6 (AAAA)**: {}",
993                    dns.aaaa_records
994                        .iter()
995                        .map(|ip| format!("`{}`", MdSafe(ip)))
996                        .collect::<Vec<_>>()
997                        .join(", ")
998                ));
999            }
1000            if !dns.nameservers.is_empty() {
1001                output.push(format!(
1002                    "- **Nameservers**: {}",
1003                    dns.nameservers
1004                        .iter()
1005                        .map(|ns| format!("`{}`", MdSafe(ns)))
1006                        .collect::<Vec<_>>()
1007                        .join(", ")
1008                ));
1009            }
1010        } else {
1011            output.push("### DNS Resolution".to_string());
1012            output.push(String::new());
1013            output.push("*Check failed*".to_string());
1014        }
1015
1016        output.join("\n")
1017    }
1018
1019    fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
1020        let time_str = iteration.timestamp.format("%H:%M:%S").to_string();
1021
1022        if let Some(ref error) = iteration.error {
1023            return format!(
1024                "[{}] Iteration {}/{}: **ERROR** - {}",
1025                time_str,
1026                iteration.iteration,
1027                iteration.total_iterations,
1028                MdSafe(error)
1029            );
1030        }
1031
1032        let record_count = iteration.record_count();
1033        let status = if iteration.iteration == 1 {
1034            String::new()
1035        } else if iteration.changed {
1036            " (**CHANGED**)".to_string()
1037        } else {
1038            " (unchanged)".to_string()
1039        };
1040
1041        let values: Vec<String> = iteration
1042            .records
1043            .iter()
1044            .map(|r| r.data.to_string().trim_end_matches('.').to_string())
1045            .collect();
1046
1047        let values_str = if values.is_empty() {
1048            String::new()
1049        } else {
1050            let joined = values.join(", ");
1051            format!(" `{}`", MdSafe(&joined))
1052        };
1053
1054        format!(
1055            "[{}] Iteration {}/{}: {} record(s){}{}",
1056            time_str,
1057            iteration.iteration,
1058            iteration.total_iterations,
1059            record_count,
1060            status,
1061            values_str
1062        )
1063    }
1064
1065    fn format_follow(&self, result: &FollowResult) -> String {
1066        let mut output = Vec::new();
1067
1068        output.push(format!(
1069            "## DNS Follow: {} {}",
1070            MdSafe(&result.domain),
1071            result.record_type
1072        ));
1073        output.push(String::new());
1074
1075        // Summary
1076        output.push(format!(
1077            "- **Iterations**: {}/{}",
1078            result.completed_iterations(),
1079            result.iterations_requested
1080        ));
1081
1082        if result.interrupted {
1083            output.push("- **Status**: Interrupted".to_string());
1084        }
1085
1086        output.push(format!("- **Total changes**: {}", result.total_changes));
1087
1088        let duration = result.ended_at - result.started_at;
1089        let total_secs = duration.num_seconds();
1090        let duration_str = if total_secs < 60 {
1091            format!("{}s", total_secs)
1092        } else if total_secs < 3600 {
1093            format!("{}m {}s", total_secs / 60, total_secs % 60)
1094        } else {
1095            format!("{}h {}m", total_secs / 3600, (total_secs % 3600) / 60)
1096        };
1097        output.push(format!("- **Duration**: {}", duration_str));
1098
1099        // Iteration details table
1100        if !result.iterations.is_empty() {
1101            output.push(String::new());
1102            output.push("### Iteration Details".to_string());
1103            output.push(String::new());
1104            output.push("| # | Time | Records | Status |".to_string());
1105            output.push("| --- | --- | --- | --- |".to_string());
1106
1107            for iteration in &result.iterations {
1108                let time_str = iteration.timestamp.format("%H:%M:%S").to_string();
1109                let status = if iteration.error.is_some() {
1110                    "ERROR"
1111                } else if iteration.changed {
1112                    "CHANGED"
1113                } else if iteration.iteration == 1 {
1114                    "initial"
1115                } else {
1116                    "stable"
1117                };
1118
1119                output.push(format!(
1120                    "| {} | {} | {} | {} |",
1121                    iteration.iteration,
1122                    time_str,
1123                    iteration.record_count(),
1124                    status
1125                ));
1126            }
1127        }
1128
1129        output.join("\n")
1130    }
1131
1132    fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
1133        let mut output = Vec::new();
1134
1135        output.push(format!("## Availability: {}", MdSafe(&result.domain)));
1136        output.push(String::new());
1137
1138        let avail_str = if result.available {
1139            "**AVAILABLE**"
1140        } else {
1141            "**TAKEN**"
1142        };
1143        output.push(format!("- **Result**: {}", avail_str));
1144        output.push(format!("- **Confidence**: {}", result.confidence));
1145        output.push(format!("- **Method**: {}", result.method));
1146        if let Some(ref details) = result.details {
1147            output.push(format!("- **Details**: {}", MdSafe(details)));
1148        }
1149
1150        output.join("\n")
1151    }
1152
1153    fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
1154        let mut output = Vec::new();
1155
1156        output.push(format!("## TLD Info: .{}", MdSafe(&info.tld)));
1157        output.push(String::new());
1158
1159        output.push(format!("- **Type**: {}", MdSafe(&info.tld_type)));
1160
1161        match info.whois_server {
1162            Some(ref server) => {
1163                output.push(format!("- **WHOIS Server**: `{}`", MdSafe(server)));
1164            }
1165            None => output.push("- **WHOIS Server**: *not available*".to_string()),
1166        }
1167
1168        match info.rdap_url {
1169            Some(ref url) => output.push(format!("- **RDAP URL**: `{}`", MdSafe(url))),
1170            None => output.push("- **RDAP URL**: *not available*".to_string()),
1171        }
1172
1173        match info.registry_url {
1174            Some(ref url) => output.push(format!("- **Registry URL**: {}", MdSafe(url))),
1175            None => output.push("- **Registry URL**: *not available*".to_string()),
1176        }
1177
1178        output.join("\n")
1179    }
1180
1181    fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
1182        let mut output = Vec::new();
1183
1184        output.push(format!("## DNSSEC: {}", MdSafe(&report.domain)));
1185        output.push(String::new());
1186
1187        output.push(format!("- **Status**: `{}`", MdSafe(&report.status)));
1188        output.push(format!(
1189            "- **Chain Valid**: {}",
1190            if report.chain_valid { "yes" } else { "no" }
1191        ));
1192        output.push(format!("- **Enabled**: {}", report.enabled));
1193        output.push(format!("- **DS Records**: {}", report.ds_records.len()));
1194        output.push(format!(
1195            "- **DNSKEY Records**: {}",
1196            report.dnskey_records.len()
1197        ));
1198
1199        if !report.ds_records.is_empty() {
1200            output.push(String::new());
1201            output.push("### DS Records".to_string());
1202            output.push(String::new());
1203            output.push("| Key Tag | Algorithm | Digest Type | Matched | Verified |".to_string());
1204            output.push("| --- | --- | --- | --- | --- |".to_string());
1205            for ds in &report.ds_records {
1206                output.push(format!(
1207                    "| {} | {} ({}) | {} ({}) | {} | {} |",
1208                    ds.key_tag,
1209                    ds.algorithm,
1210                    ds.algorithm_name,
1211                    ds.digest_type,
1212                    ds.digest_type_name,
1213                    if ds.matched_key { "yes" } else { "no" },
1214                    if ds.digest_verified { "yes" } else { "no" },
1215                ));
1216            }
1217        }
1218
1219        if !report.dnskey_records.is_empty() {
1220            output.push(String::new());
1221            output.push("### DNSKEY Records".to_string());
1222            output.push(String::new());
1223            output.push("| Key Tag | Flags | Role | Algorithm |".to_string());
1224            output.push("| --- | --- | --- | --- |".to_string());
1225            for key in &report.dnskey_records {
1226                let role = if key.is_ksk {
1227                    "KSK"
1228                } else if key.is_zsk {
1229                    "ZSK"
1230                } else {
1231                    "Other"
1232                };
1233                output.push(format!(
1234                    "| {} | {} | {} | {} ({}) |",
1235                    key.key_tag, key.flags, role, key.algorithm, key.algorithm_name
1236                ));
1237            }
1238        }
1239
1240        if !report.issues.is_empty() {
1241            output.push(String::new());
1242            output.push("### Issues".to_string());
1243            output.push(String::new());
1244            for issue in &report.issues {
1245                output.push(format!("- {}", MdSafe(issue)));
1246            }
1247        }
1248
1249        output.join("\n")
1250    }
1251
1252    fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
1253        let mut output = Vec::new();
1254
1255        output.push(format!(
1256            "## DNS Comparison: {} {}",
1257            MdSafe(&comparison.domain),
1258            comparison.record_type
1259        ));
1260        output.push(String::new());
1261
1262        if comparison.matches {
1263            output.push("**Result**: Records match".to_string());
1264        } else {
1265            output.push("**Result**: Records differ".to_string());
1266        }
1267        output.push(String::new());
1268
1269        // Server A
1270        output.push(format!(
1271            "### Server A ({})",
1272            MdSafe(&comparison.server_a.nameserver)
1273        ));
1274        output.push(String::new());
1275        if let Some(ref err) = comparison.server_a.error {
1276            output.push(format!("**Error**: {}", MdSafe(err)));
1277        } else if comparison.server_a.records.is_empty() {
1278            output.push("*No records found*".to_string());
1279        } else {
1280            output.push("| Record |".to_string());
1281            output.push("| --- |".to_string());
1282            for record in &comparison.server_a.records {
1283                let s = record.format_short();
1284                output.push(format!("| `{}` |", MdSafe(&s)));
1285            }
1286        }
1287        output.push(String::new());
1288
1289        // Server B
1290        output.push(format!(
1291            "### Server B ({})",
1292            MdSafe(&comparison.server_b.nameserver)
1293        ));
1294        output.push(String::new());
1295        if let Some(ref err) = comparison.server_b.error {
1296            output.push(format!("**Error**: {}", MdSafe(err)));
1297        } else if comparison.server_b.records.is_empty() {
1298            output.push("*No records found*".to_string());
1299        } else {
1300            output.push("| Record |".to_string());
1301            output.push("| --- |".to_string());
1302            for record in &comparison.server_b.records {
1303                let s = record.format_short();
1304                output.push(format!("| `{}` |", MdSafe(&s)));
1305            }
1306        }
1307        output.push(String::new());
1308
1309        // Differences
1310        output.push("### Comparison".to_string());
1311        output.push(String::new());
1312
1313        if comparison.common.is_empty() {
1314            output.push("- **Common**: *(none)*".to_string());
1315        } else {
1316            output.push(format!(
1317                "- **Common**: {}",
1318                comparison
1319                    .common
1320                    .iter()
1321                    .map(|r| format!("`{}`", MdSafe(r)))
1322                    .collect::<Vec<_>>()
1323                    .join(", ")
1324            ));
1325        }
1326
1327        if comparison.only_in_a.is_empty() {
1328            output.push(format!(
1329                "- **Only in {}**: *(none)*",
1330                MdSafe(&comparison.server_a.nameserver)
1331            ));
1332        } else {
1333            output.push(format!(
1334                "- **Only in {}**: {}",
1335                MdSafe(&comparison.server_a.nameserver),
1336                comparison
1337                    .only_in_a
1338                    .iter()
1339                    .map(|r| format!("`{}`", MdSafe(r)))
1340                    .collect::<Vec<_>>()
1341                    .join(", ")
1342            ));
1343        }
1344
1345        if comparison.only_in_b.is_empty() {
1346            output.push(format!(
1347                "- **Only in {}**: *(none)*",
1348                MdSafe(&comparison.server_b.nameserver)
1349            ));
1350        } else {
1351            output.push(format!(
1352                "- **Only in {}**: {}",
1353                MdSafe(&comparison.server_b.nameserver),
1354                comparison
1355                    .only_in_b
1356                    .iter()
1357                    .map(|r| format!("`{}`", MdSafe(r)))
1358                    .collect::<Vec<_>>()
1359                    .join(", ")
1360            ));
1361        }
1362
1363        output.join("\n")
1364    }
1365
1366    fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
1367        let mut output = Vec::new();
1368
1369        output.push(format!("## Subdomains: {}", MdSafe(&result.domain)));
1370        output.push(String::new());
1371        output.push(format!("- **Source**: {}", MdSafe(&result.source)));
1372        output.push(format!("- **Count**: {}", result.count));
1373        output.push(String::new());
1374
1375        if result.subdomains.is_empty() {
1376            output.push("*No subdomains found*".to_string());
1377        } else {
1378            for subdomain in &result.subdomains {
1379                output.push(format!("- `{}`", MdSafe(subdomain)));
1380            }
1381        }
1382
1383        output.join("\n")
1384    }
1385
1386    fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
1387        let dash = "\u{2014}";
1388        let mut output = Vec::new();
1389
1390        // Helper for "value or dash"; wraps attacker-controlled value in MdSafe.
1391        let opt_or_dash = |o: &Option<String>| -> String {
1392            match o {
1393                Some(v) => format!("{}", MdSafe(v)),
1394                None => dash.to_string(),
1395            }
1396        };
1397        let list_or_dash = |v: &Vec<String>| -> String {
1398            if v.is_empty() {
1399                dash.to_string()
1400            } else {
1401                let joined = v.join("`, `");
1402                format!("`{}`", MdSafe(&joined))
1403            }
1404        };
1405
1406        output.push(format!(
1407            "## Domain Comparison: {} vs {}",
1408            MdSafe(&diff.domain_a),
1409            MdSafe(&diff.domain_b)
1410        ));
1411        output.push(String::new());
1412
1413        // Registration table
1414        output.push("### Registration".to_string());
1415        output.push(String::new());
1416        output.push(format!(
1417            "| Field | {} | {} |",
1418            MdSafe(&diff.domain_a),
1419            MdSafe(&diff.domain_b)
1420        ));
1421        output.push("| --- | --- | --- |".to_string());
1422
1423        let reg = &diff.registration;
1424        output.push(format!(
1425            "| Registrar | {} | {} |",
1426            opt_or_dash(&reg.registrar.0),
1427            opt_or_dash(&reg.registrar.1)
1428        ));
1429        output.push(format!(
1430            "| Organization | {} | {} |",
1431            opt_or_dash(&reg.organization.0),
1432            opt_or_dash(&reg.organization.1)
1433        ));
1434        output.push(format!(
1435            "| Created | {} | {} |",
1436            opt_or_dash(&reg.created.0),
1437            opt_or_dash(&reg.created.1)
1438        ));
1439        output.push(format!(
1440            "| Expires | {} | {} |",
1441            opt_or_dash(&reg.expires.0),
1442            opt_or_dash(&reg.expires.1)
1443        ));
1444
1445        // DNS table
1446        output.push(String::new());
1447        output.push("### DNS".to_string());
1448        output.push(String::new());
1449        output.push(format!(
1450            "| Field | {} | {} |",
1451            MdSafe(&diff.domain_a),
1452            MdSafe(&diff.domain_b)
1453        ));
1454        output.push("| --- | --- | --- |".to_string());
1455        let dns = &diff.dns;
1456        output.push(format!(
1457            "| Resolves | {} | {} |",
1458            if dns.resolves.0 { "yes" } else { "no" },
1459            if dns.resolves.1 { "yes" } else { "no" }
1460        ));
1461        let a_recs_a = list_or_dash(&dns.a_records.0);
1462        let a_recs_b = list_or_dash(&dns.a_records.1);
1463        output.push(format!("| A Records | {} | {} |", a_recs_a, a_recs_b));
1464        let ns_a = list_or_dash(&dns.nameservers.0);
1465        let ns_b = list_or_dash(&dns.nameservers.1);
1466        output.push(format!("| Nameservers | {} | {} |", ns_a, ns_b));
1467
1468        // SSL table
1469        output.push(String::new());
1470        output.push("### SSL".to_string());
1471        output.push(String::new());
1472        output.push(format!(
1473            "| Field | {} | {} |",
1474            MdSafe(&diff.domain_a),
1475            MdSafe(&diff.domain_b)
1476        ));
1477        output.push("| --- | --- | --- |".to_string());
1478        let ssl = &diff.ssl;
1479        output.push(format!(
1480            "| Issuer | {} | {} |",
1481            opt_or_dash(&ssl.issuer.0),
1482            opt_or_dash(&ssl.issuer.1)
1483        ));
1484        output.push(format!(
1485            "| Valid Until | {} | {} |",
1486            opt_or_dash(&ssl.valid_until.0),
1487            opt_or_dash(&ssl.valid_until.1)
1488        ));
1489        output.push(format!(
1490            "| Days Remaining | {} | {} |",
1491            ssl.days_remaining
1492                .0
1493                .map(|d| d.to_string())
1494                .as_deref()
1495                .unwrap_or(dash),
1496            ssl.days_remaining
1497                .1
1498                .map(|d| d.to_string())
1499                .as_deref()
1500                .unwrap_or(dash)
1501        ));
1502        output.push(format!(
1503            "| Valid | {} | {} |",
1504            ssl.is_valid
1505                .0
1506                .map(|v| if v { "yes" } else { "no" })
1507                .unwrap_or(dash),
1508            ssl.is_valid
1509                .1
1510                .map(|v| if v { "yes" } else { "no" })
1511                .unwrap_or(dash)
1512        ));
1513
1514        output.join("\n")
1515    }
1516
1517    fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
1518        let mut output = Vec::new();
1519
1520        output.push(format!("## SSL Report: {}", MdSafe(&report.domain)));
1521        output.push(String::new());
1522
1523        output.push(format!(
1524            "- **Valid**: {}",
1525            if report.is_valid { "yes" } else { "no" }
1526        ));
1527        output.push(format!(
1528            "- **Days Until Expiry**: {}",
1529            report.days_until_expiry
1530        ));
1531
1532        if let Some(ref proto) = report.protocol_version {
1533            output.push(format!("- **Protocol**: {}", MdSafe(proto)));
1534        }
1535
1536        if !report.san_names.is_empty() {
1537            output.push(format!(
1538                "- **SANs**: {}",
1539                report
1540                    .san_names
1541                    .iter()
1542                    .map(|s| format!("`{}`", MdSafe(s)))
1543                    .collect::<Vec<_>>()
1544                    .join(", ")
1545            ));
1546        }
1547
1548        if !report.chain.is_empty() {
1549            output.push(String::new());
1550            output.push("### Certificate Chain".to_string());
1551            output.push(String::new());
1552            output.push("| # | Subject | Issuer | Valid Until | Key |".to_string());
1553            output.push("| --- | --- | --- | --- | --- |".to_string());
1554            for (i, cert) in report.chain.iter().enumerate() {
1555                let key_info = match (&cert.key_type, cert.key_bits) {
1556                    (Some(kt), Some(bits)) => format!("{} ({} bits)", MdSafe(kt), bits),
1557                    (Some(kt), None) => format!("{}", MdSafe(kt)),
1558                    _ => "N/A".to_string(),
1559                };
1560                output.push(format!(
1561                    "| {} | {} | {} | {} | {} |",
1562                    i,
1563                    MdSafe(&cert.subject),
1564                    MdSafe(&cert.issuer),
1565                    cert.valid_until.format("%Y-%m-%d"),
1566                    key_info
1567                ));
1568            }
1569        }
1570
1571        if let Some(ref caa) = report.caa {
1572            output.extend(self.render_caa_section(caa));
1573        }
1574
1575        output.join("\n")
1576    }
1577
1578    fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
1579        let mut output = Vec::new();
1580
1581        output.push("## Domain Watch Report".to_string());
1582        output.push(String::new());
1583        output.push(format!(
1584            "- **Checked**: {}",
1585            report.checked_at.format("%Y-%m-%d %H:%M:%S UTC")
1586        ));
1587        output.push(format!(
1588            "- **Total**: {} domains, {} warnings, {} critical",
1589            report.total, report.warnings, report.critical
1590        ));
1591        output.push(String::new());
1592
1593        if report.results.is_empty() {
1594            output.push("No domains in watchlist.".to_string());
1595            return output.join("\n");
1596        }
1597
1598        output.push("| Status | Domain | SSL Days | Domain Days | HTTP | Issues |".to_string());
1599        output.push("| --- | --- | --- | --- | --- | --- |".to_string());
1600
1601        for r in &report.results {
1602            let icon = if r.issues.is_empty() { "ok" } else { "warn" };
1603            let ssl = r
1604                .ssl_days_remaining
1605                .map(|d| d.to_string())
1606                .unwrap_or_else(|| "N/A".to_string());
1607            let dom = r
1608                .domain_days_remaining
1609                .map(|d| d.to_string())
1610                .unwrap_or_else(|| "N/A".to_string());
1611            let http = r
1612                .http_status
1613                .map(|s| s.to_string())
1614                .unwrap_or_else(|| "N/A".to_string());
1615            let issues = if r.issues.is_empty() {
1616                "-".to_string()
1617            } else {
1618                r.issues.join("; ")
1619            };
1620            output.push(format!(
1621                "| {} | {} | {} | {} | {} | {} |",
1622                icon,
1623                MdSafe(&r.domain),
1624                ssl,
1625                dom,
1626                http,
1627                MdSafe(&issues)
1628            ));
1629        }
1630
1631        output.join("\n")
1632    }
1633
1634    fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
1635        let mut output = Vec::new();
1636
1637        let source_str = match info.source {
1638            crate::domain_info::DomainInfoSource::Both => "both",
1639            crate::domain_info::DomainInfoSource::Rdap => "rdap",
1640            crate::domain_info::DomainInfoSource::Whois => "whois",
1641            crate::domain_info::DomainInfoSource::Available => "available",
1642        };
1643
1644        // Helper: render Option<String> via MdSafe or fall back to "-".
1645        let opt_md = |o: &Option<String>| -> String {
1646            match o {
1647                Some(v) => format!("{}", MdSafe(v)),
1648                None => "-".to_string(),
1649            }
1650        };
1651
1652        output.push(format!("## Domain Info: {}", MdSafe(&info.domain)));
1653        output.push(String::new());
1654        output.push(format!("**Source:** {}", source_str));
1655        output.push(String::new());
1656
1657        // Registration table
1658        output.push("### Registration".to_string());
1659        output.push(String::new());
1660        output.push("| Field | Value |".to_string());
1661        output.push("| --- | --- |".to_string());
1662        output.push(format!("| Registrar | {} |", opt_md(&info.registrar)));
1663        output.push(format!("| Registrant | {} |", opt_md(&info.registrant)));
1664        output.push(format!("| Organization | {} |", opt_md(&info.organization)));
1665        output.push(format!(
1666            "| Created | {} |",
1667            info.creation_date
1668                .map(|d| d.format("%Y-%m-%d").to_string())
1669                .as_deref()
1670                .unwrap_or("-")
1671        ));
1672        output.push(format!(
1673            "| Expires | {} |",
1674            info.expiration_date
1675                .map(|d| d.format("%Y-%m-%d").to_string())
1676                .as_deref()
1677                .unwrap_or("-")
1678        ));
1679        output.push(format!(
1680            "| Updated | {} |",
1681            info.updated_date
1682                .map(|d| d.format("%Y-%m-%d").to_string())
1683                .as_deref()
1684                .unwrap_or("-")
1685        ));
1686        output.push(format!(
1687            "| Nameservers | {} |",
1688            if info.nameservers.is_empty() {
1689                "-".to_string()
1690            } else {
1691                info.nameservers
1692                    .iter()
1693                    .map(|ns| format!("`{}`", MdSafe(ns)))
1694                    .collect::<Vec<_>>()
1695                    .join(", ")
1696            }
1697        ));
1698        output.push(format!(
1699            "| Status | {} |",
1700            if info.status.is_empty() {
1701                "-".to_string()
1702            } else {
1703                info.status
1704                    .iter()
1705                    .map(|s| format!("`{}`", MdSafe(s)))
1706                    .collect::<Vec<_>>()
1707                    .join(", ")
1708            }
1709        ));
1710        output.push(format!("| DNSSEC | {} |", opt_md(&info.dnssec)));
1711
1712        // Contacts table
1713        let has_any_contact = info.registrant_email.is_some()
1714            || info.registrant_phone.is_some()
1715            || info.registrant_address.is_some()
1716            || info.registrant_country.is_some()
1717            || info.admin_name.is_some()
1718            || info.admin_organization.is_some()
1719            || info.admin_email.is_some()
1720            || info.admin_phone.is_some()
1721            || info.tech_name.is_some()
1722            || info.tech_organization.is_some()
1723            || info.tech_email.is_some()
1724            || info.tech_phone.is_some();
1725
1726        if has_any_contact {
1727            output.push(String::new());
1728            output.push("### Contacts".to_string());
1729            output.push(String::new());
1730            output.push("| Role | Name | Organization | Email | Phone |".to_string());
1731            output.push("| --- | --- | --- | --- | --- |".to_string());
1732
1733            let has_registrant = info.registrant_email.is_some()
1734                || info.registrant_phone.is_some()
1735                || info.registrant_address.is_some()
1736                || info.registrant_country.is_some();
1737            if has_registrant {
1738                output.push(format!(
1739                    "| Registrant | - | - | {} | {} |",
1740                    opt_md(&info.registrant_email),
1741                    opt_md(&info.registrant_phone),
1742                ));
1743            }
1744
1745            let has_admin = info.admin_name.is_some()
1746                || info.admin_organization.is_some()
1747                || info.admin_email.is_some()
1748                || info.admin_phone.is_some();
1749            if has_admin {
1750                output.push(format!(
1751                    "| Admin | {} | {} | {} | {} |",
1752                    opt_md(&info.admin_name),
1753                    opt_md(&info.admin_organization),
1754                    opt_md(&info.admin_email),
1755                    opt_md(&info.admin_phone),
1756                ));
1757            }
1758
1759            let has_tech = info.tech_name.is_some()
1760                || info.tech_organization.is_some()
1761                || info.tech_email.is_some()
1762                || info.tech_phone.is_some();
1763            if has_tech {
1764                output.push(format!(
1765                    "| Tech | {} | {} | {} | {} |",
1766                    opt_md(&info.tech_name),
1767                    opt_md(&info.tech_organization),
1768                    opt_md(&info.tech_email),
1769                    opt_md(&info.tech_phone),
1770                ));
1771            }
1772        }
1773
1774        // Protocol Metadata
1775        let has_metadata = info.whois_server.is_some() || info.rdap_url.is_some();
1776        if has_metadata {
1777            output.push(String::new());
1778            output.push("### Protocol Metadata".to_string());
1779            output.push(String::new());
1780            if let Some(ref whois_server) = info.whois_server {
1781                output.push(format!("- **WHOIS Server**: `{}`", MdSafe(whois_server)));
1782            }
1783            if let Some(ref rdap_url) = info.rdap_url {
1784                output.push(format!("- **RDAP URL**: `{}`", MdSafe(rdap_url)));
1785            }
1786        }
1787
1788        output.join("\n")
1789    }
1790}
1791
1792#[cfg(test)]
1793mod tests {
1794    use super::*;
1795    use crate::dns::RecordType;
1796    use crate::status::StatusResponse;
1797
1798    #[test]
1799    fn test_markdown_format_status() {
1800        let response = StatusResponse::new("example.com".to_string());
1801        let formatter = MarkdownFormatter::new();
1802        let output = formatter.format_status(&response);
1803        assert!(output.contains("## Status: example.com"));
1804        assert!(output.contains("### SSL Certificate"));
1805        assert!(output.contains("### DNS Resolution"));
1806    }
1807
1808    #[test]
1809    fn test_markdown_format_dns_records() {
1810        let records = vec![DnsRecord {
1811            name: "example.com".to_string(),
1812            record_type: RecordType::A,
1813            ttl: 300,
1814            data: crate::dns::RecordData::A {
1815                address: "93.184.216.34".to_string(),
1816            },
1817        }];
1818        let formatter = MarkdownFormatter::new();
1819        let output = formatter.format_dns(&records);
1820        assert!(output.contains("## DNS A Records: example.com"));
1821        assert!(output.contains("| Name | TTL | Type | Data |"));
1822        assert!(output.contains("93.184.216.34"));
1823        assert!(
1824            output.contains("DNSSEC-validated"),
1825            "DNS output must disclose DNSSEC is not validated"
1826        );
1827    }
1828
1829    #[test]
1830    fn test_markdown_format_dns_empty() {
1831        let formatter = MarkdownFormatter::new();
1832        let output = formatter.format_dns(&[]);
1833        assert!(output.contains("No records found"));
1834        assert!(output.contains("DNSSEC-validated"));
1835    }
1836
1837    #[test]
1838    fn test_markdown_format_availability() {
1839        let result = crate::availability::AvailabilityResult {
1840            domain: "test.com".to_string(),
1841            available: true,
1842            confidence: "high".to_string(),
1843            method: "RDAP+WHOIS".to_string(),
1844            details: Some("Domain not found".to_string()),
1845        };
1846        let formatter = MarkdownFormatter::new();
1847        let output = formatter.format_availability(&result);
1848        assert!(output.contains("## Availability: test.com"));
1849        assert!(output.contains("**AVAILABLE**"));
1850        assert!(output.contains("high"));
1851    }
1852
1853    // --- MdSafe sanitization tests -----------------------------------------
1854
1855    fn md(s: &str) -> String {
1856        format!("{}", MdSafe(s))
1857    }
1858
1859    #[test]
1860    fn test_mdsafe_strips_ansi_escape() {
1861        assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
1862    }
1863
1864    #[test]
1865    fn test_mdsafe_collapses_newlines_cr_tab() {
1866        assert_eq!(md("a\nb"), "a b");
1867        assert_eq!(md("a\rb"), "a b");
1868        assert_eq!(md("a\tb"), "a b");
1869        // CRLF becomes two spaces (each replaced individually); that's fine —
1870        // the goal is to prevent breaking the line, not perfect whitespace.
1871        assert_eq!(md("a\r\nb"), "a  b");
1872    }
1873
1874    #[test]
1875    fn test_mdsafe_neutralizes_backticks() {
1876        assert_eq!(md("`bad`"), "'bad'");
1877        assert_eq!(md("a `b` c"), "a 'b' c");
1878    }
1879
1880    #[test]
1881    fn test_mdsafe_drops_other_control_chars() {
1882        // NUL and DEL must vanish entirely.
1883        assert_eq!(md("a\0b\x7fc"), "abc");
1884    }
1885
1886    #[test]
1887    fn test_mdsafe_preserves_unicode() {
1888        assert_eq!(md("café — résumé"), "café — résumé");
1889    }
1890
1891    fn empty_whois(domain: &str) -> WhoisResponse {
1892        WhoisResponse {
1893            domain: domain.to_string(),
1894            registrar: None,
1895            registrant: None,
1896            organization: None,
1897            registrant_email: None,
1898            registrant_phone: None,
1899            registrant_address: None,
1900            registrant_country: None,
1901            admin_name: None,
1902            admin_organization: None,
1903            admin_email: None,
1904            admin_phone: None,
1905            tech_name: None,
1906            tech_organization: None,
1907            tech_email: None,
1908            tech_phone: None,
1909            creation_date: None,
1910            expiration_date: None,
1911            updated_date: None,
1912            nameservers: vec![],
1913            status: vec!["clientTransferProhibited".to_string()],
1914            dnssec: None,
1915            whois_server: "whois.example.invalid".to_string(),
1916            raw_response: String::new(),
1917        }
1918    }
1919
1920    #[test]
1921    fn test_markdown_whois_registrar_newline_neutralized() {
1922        let mut w = empty_whois("example.com");
1923        w.registrar = Some("Foo\nIgnore previous".to_string());
1924        let output = MarkdownFormatter::new().format_whois(&w);
1925        // Newline must be collapsed to a single space so attacker text can't
1926        // start a new Markdown line / heading.
1927        assert!(
1928            output.contains("- **Registrar**: Foo Ignore previous"),
1929            "expected sanitized registrar in output:\n{}",
1930            output
1931        );
1932        // Make sure the literal newline did not survive inside the registrar
1933        // value: split on lines and check no line starts with "Ignore".
1934        for line in output.lines() {
1935            assert!(
1936                !line.trim_start().starts_with("Ignore previous"),
1937                "attacker payload broke onto its own line:\n{}",
1938                output
1939            );
1940        }
1941    }
1942
1943    #[test]
1944    fn test_markdown_rdap_entity_name_backtick_neutralized() {
1945        use crate::rdap::RdapResponse;
1946
1947        // Construct via serde JSON to avoid needing to import the private
1948        // `RdapEntity` type. `get_registrar()` falls back to `handle` when
1949        // there is no vCard `fn` property.
1950        let json = serde_json::json!({
1951            "ldhName": "example.com",
1952            "entities": [
1953                {
1954                    "objectClassName": "entity",
1955                    "handle": "Evil`Registrar`Co",
1956                    "roles": ["registrar"],
1957                }
1958            ]
1959        });
1960        let response: RdapResponse = serde_json::from_value(json).unwrap();
1961
1962        let output = MarkdownFormatter::new().format_rdap(&response);
1963        // Backtick must be rendered as a single quote, not as a literal
1964        // backtick (which could close the surrounding code span and let an
1965        // attacker inject Markdown).
1966        assert!(
1967            output.contains("- **Registrar**: Evil'Registrar'Co"),
1968            "expected backticks neutralized in RDAP output:\n{}",
1969            output
1970        );
1971        assert!(
1972            !output.contains("Evil`Registrar`Co"),
1973            "raw backticks survived into output:\n{}",
1974            output
1975        );
1976    }
1977}