Skip to main content

seer_core/output/markdown/
mod.rs

1//! Markdown output (`--format markdown`). One inherent `format_*` method per
2//! report type, split into per-concern submodules; bullets go through the
3//! private `Bullets` writer and `code_list`, and DNS record tables through
4//! `record_table`, which escape every value so domain data can't inject
5//! markdown: `MdSafe` for text, `MdCode` inside a code span and `MdCodeCell`
6//! inside a code span in a table cell.
7
8use std::fmt::{self, Write as _};
9
10use chrono::{DateTime, Utc};
11
12use super::OutputFormatter;
13
14// Shared with the per-concern submodules below (each does `use super::*`).
15pub(super) use super::contact::{self, Contact, FlatContacts};
16pub(super) use super::grouping::render_grouped;
17pub(super) use super::{
18    days_until, dnssec_depth, expiry_phrase, format_duration, propagation_detail,
19    propagation_difference, DNSSEC_NOTE, GEO_NOTE,
20};
21pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
22pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
23pub(super) use crate::lookup::LookupResult;
24pub(super) use crate::rdap::RdapResponse;
25pub(super) use crate::status::StatusResponse;
26pub(super) use crate::whois::WhoisResponse;
27
28mod delegation;
29mod diff;
30mod dig;
31mod dns;
32mod domain_info;
33mod lookup;
34mod propagation;
35mod rdap;
36mod security;
37mod status;
38mod whois;
39
40/// Where escaped text lands, which decides what must be escaped.
41#[derive(Clone, Copy, PartialEq, Eq)]
42enum Context {
43    /// Running text: every Markdown metacharacter that could change
44    /// structure is backslash-escaped.
45    Text,
46    /// Inside a code span outside a table. Backslash escapes are not
47    /// processed there (`\<` would print both characters), so only what can
48    /// end the span or the line matters.
49    Code,
50    /// Inside a code span in a GFM table cell. The table splits its row on
51    /// `|` before the span is parsed, so a pipe still needs `\|` here.
52    CodeCell,
53}
54
55/// Writes attacker-controlled WHOIS/RDAP/DNS/SSL text into Markdown for
56/// `context`. Whatever the context, ANSI escape sequences, control
57/// characters and invisible bidi/zero-width format characters
58/// (`is_invisible_format`) are dropped, newlines/CR/tabs and the Unicode
59/// line separators collapse to spaces (so the text cannot break out of a
60/// table row or start a heading), and backticks become apostrophes (so it
61/// cannot close a code span or open one).
62///
63/// In [`Context::Text`], `\` `[` `]` `<` `>` `|` are backslash-escaped, so a
64/// value can't render as a live link, image, autolink or raw HTML (e.g. a
65/// registrar of `![x](https://attacker.example/p.png)`), can't add table
66/// columns, and can't smuggle in a backslash that would cancel the next
67/// escape (`\[Verify\](…)` would otherwise become `\\[Verify\\](…)`, a live
68/// link). `(`/`)` are left alone: without a `]` before them they carry no
69/// link syntax. In [`Context::CodeCell`] a `|` is written `\|`, and a
70/// backslash right before a pipe is doubled too, so the table's own
71/// escape pairing still sees an escaped pipe.
72fn write_escaped(f: &mut fmt::Formatter<'_>, text: &str, context: Context) -> fmt::Result {
73    let mut iter = text.chars().peekable();
74    while let Some(c) = iter.next() {
75        match c {
76            '\x1b' => consume_escape(&mut iter),
77            '\n' | '\r' | '\t' | '\u{2028}' | '\u{2029}' => f.write_str(" ")?,
78            '`' => f.write_str("'")?,
79            '\\' | '[' | ']' | '<' | '>' | '|' if context == Context::Text => {
80                f.write_char('\\')?;
81                f.write_char(c)?;
82            }
83            '|' if context == Context::CodeCell => f.write_str("\\|")?,
84            '\\' if context == Context::CodeCell && iter.peek() == Some(&'|') => {
85                f.write_str("\\\\")?;
86            }
87            c if c.is_control() || super::is_invisible_format(c) => {}
88            c => f.write_char(c)?,
89        }
90    }
91    Ok(())
92}
93
94/// `Display` adapter for remote text in running Markdown ([`Context::Text`]).
95pub(super) struct MdSafe<'a>(pub &'a str);
96
97impl fmt::Display for MdSafe<'_> {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        write_escaped(f, self.0, Context::Text)
100    }
101}
102
103/// `Display` adapter for remote text inside a `` `code span` `` outside a
104/// table ([`Context::Code`]); the caller writes the backticks.
105pub(super) struct MdCode<'a>(pub &'a str);
106
107impl fmt::Display for MdCode<'_> {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write_escaped(f, self.0, Context::Code)
110    }
111}
112
113/// `Display` adapter for remote text inside a `` `code span` `` in a table
114/// cell ([`Context::CodeCell`]); the caller writes the backticks.
115pub(super) struct MdCodeCell<'a>(pub &'a str);
116
117impl fmt::Display for MdCodeCell<'_> {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        write_escaped(f, self.0, Context::CodeCell)
120    }
121}
122
123/// Maximum chars consumed while scanning for an ANSI escape terminator. A
124/// well-formed CSI/OSC/DCS sequence is far shorter; the cap stops a malformed
125/// sequence from eating an unbounded run of legitimate text.
126const ANSI_SCAN_CAP: usize = 64;
127
128/// Consumes the remainder of an ANSI escape sequence after the leading ESC has
129/// already been read, given a peekable char iterator positioned at the
130/// introducer.
131///
132/// Critically, the introducer is only consumed when it is *actually* one — a
133/// lone/truncated ESC (e.g. `ESC` then `X` or `|`) drops only the ESC and
134/// leaves the following character for normal escaping, instead of swallowing
135/// it. Scans also bail on a newline/CR so a terminator-less sequence can't eat
136/// across a line.
137fn consume_escape(iter: &mut std::iter::Peekable<std::str::Chars<'_>>) {
138    match iter.peek() {
139        // CSI: `ESC [` … final byte in `@`-`~` (0x40-0x7E).
140        Some('[') => {
141            iter.next(); // consume '['
142            for _ in 0..ANSI_SCAN_CAP {
143                match iter.peek() {
144                    // Bail before consuming a newline/CR — it's layout, not
145                    // part of the (malformed, unterminated) sequence.
146                    Some('\n') | Some('\r') | None => break,
147                    Some(&ch) => {
148                        iter.next();
149                        if matches!(ch as u32, 0x40..=0x7E) {
150                            break;
151                        }
152                    }
153                }
154            }
155        }
156        // String-type sequences: OSC (`ESC ]`) and DCS (`ESC P`). Both run
157        // until BEL (0x07) or ST (`ESC \\`).
158        Some(']') | Some('P') => {
159            iter.next(); // consume introducer
160            for _ in 0..ANSI_SCAN_CAP {
161                match iter.peek() {
162                    Some('\n') | Some('\r') | None => break,
163                    Some('\x07') => {
164                        iter.next();
165                        break;
166                    }
167                    Some('\x1b') => {
168                        // Possible ST: consume ESC, then `\\` if present.
169                        iter.next();
170                        if iter.peek() == Some(&'\\') {
171                            iter.next();
172                        }
173                        break;
174                    }
175                    Some(_) => {
176                        iter.next();
177                    }
178                }
179            }
180        }
181        // Charset-designation escapes: `ESC ( <id>` / `ESC ) <id>` and the
182        // 96-char variants `ESC * <id>` / `ESC + <id>`. Consume the introducer
183        // plus exactly the one designator char (never a newline).
184        Some('(') | Some(')') | Some('*') | Some('+') => {
185            iter.next(); // consume introducer
186            if !matches!(iter.peek(), Some('\n') | Some('\r') | None) {
187                iter.next();
188            }
189        }
190        // Not a recognized introducer (lone/truncated ESC). Drop only the ESC;
191        // leave the next char to be escaped normally by the main loop.
192        _ => {}
193    }
194}
195
196/// Markdown output formatter that produces clean, readable Markdown.
197#[derive(Default)]
198pub struct MarkdownFormatter;
199
200impl MarkdownFormatter {
201    pub fn new() -> Self {
202        Self
203    }
204
205    /// Renders the CAA policy as a `### CAA Policy` section shared between
206    /// SSL and status reports.
207    fn render_caa_section(&self, caa: &CaaPolicy) -> Vec<String> {
208        let mut out = vec![String::new(), "### CAA Policy".to_string(), String::new()];
209        caa_body(&mut out, caa);
210        out.extend([String::new(), format!("> **Note:** {}", caa.note)]);
211        out
212    }
213}
214
215/// The body of a CAA section: where the policy was found, its records as a
216/// table, and how the certificate's issuer compares to it.
217fn caa_body(out: &mut Vec<String>, caa: &CaaPolicy) {
218    if !caa.has_policy {
219        out.push("*No CAA records (any CA may issue)*".to_string());
220    } else {
221        Bullets(out).code_opt("Found at", &caa.effective_domain);
222        out.push(String::new());
223        out.push("| Flags | Tag | Value |".to_string());
224        out.push("| --- | --- | --- |".to_string());
225        for r in &caa.records {
226            out.push(format!(
227                "| {} | `{}` | `{}` |",
228                r.flags,
229                MdCodeCell(&r.tag),
230                MdCodeCell(&r.value)
231            ));
232        }
233    }
234
235    if let Some(m) = caa.issuer_match {
236        let rendered = match m {
237            IssuerCaaMatch::NoPolicy => "no policy — any CA permitted",
238            IssuerCaaMatch::Permitted => "issuer permitted by current CAA policy",
239            IssuerCaaMatch::Mismatch => "issuer not in current CAA policy (informational)",
240            IssuerCaaMatch::Indeterminate => "CAA present but no issue/issuewild tags",
241        };
242        out.push(String::new());
243        Bullets(out).raw("Issuer vs CAA", rendered);
244    }
245}
246
247/// `` `a`, `b` ``: each item in its own [`MdCode`] code span, joined by a
248/// plain `, ` (the separator's backticks must never pass through the
249/// escaper, which would turn them into apostrophes).
250fn code_list<S: AsRef<str>>(items: &[S]) -> String {
251    items
252        .iter()
253        .map(|item| format!("`{}`", MdCode(item.as_ref())))
254        .collect::<Vec<_>>()
255        .join(", ")
256}
257
258/// [`code_list`] for a table cell: each span escaped with [`MdCodeCell`].
259fn code_cells<S: AsRef<str>>(items: &[S]) -> String {
260    items
261        .iter()
262        .map(|item| format!("`{}`", MdCodeCell(item.as_ref())))
263        .collect::<Vec<_>>()
264        .join(", ")
265}
266
267/// Appends a `| Name | TTL | Type | Data |` table, one row per record, with
268/// the owner name and the data (remote text) in [`MdCodeCell`] code spans.
269fn record_table<'r>(out: &mut Vec<String>, records: impl IntoIterator<Item = &'r DnsRecord>) {
270    out.push("| Name | TTL | Type | Data |".to_string());
271    out.push("| --- | --- | --- | --- |".to_string());
272    for record in records {
273        out.push(format!(
274            "| `{}` | {} | {} | `{}` |",
275            MdCodeCell(&record.name),
276            record.ttl,
277            record.record_type,
278            MdCodeCell(&record.data.to_string())
279        ));
280    }
281}
282
283/// `` `YYYY-MM-DD` (expires in N days) `` — see [`expiry_phrase`].
284fn expiry(date: DateTime<Utc>, days_until: i64) -> String {
285    format!(
286        "`{}` ({})",
287        date.format("%Y-%m-%d"),
288        expiry_phrase(days_until)
289    )
290}
291
292/// Appends `- **Label**: value` bullets. Remote values go through [`MdSafe`]
293/// (or [`MdCode`] in a code span) here, so no bullet can skip the
294/// Markdown-injection guard.
295struct Bullets<'a>(&'a mut Vec<String>);
296
297impl Bullets<'_> {
298    /// Appends a pre-built line as-is.
299    fn push(&mut self, line: String) {
300        self.0.push(line);
301    }
302
303    /// `- **label**: value` for a value the caller already rendered safely.
304    fn raw(&mut self, label: &str, value: impl fmt::Display) {
305        self.0.push(format!("- **{label}**: {value}"));
306    }
307
308    /// `- **label**: value` for remote text.
309    fn text(&mut self, label: &str, value: &str) {
310        self.raw(label, MdSafe(value));
311    }
312
313    /// [`Self::text`], skipped when the field is absent.
314    fn opt(&mut self, label: &str, value: &Option<String>) {
315        if let Some(value) = value {
316            self.text(label, value);
317        }
318    }
319
320    /// `` - **label**: `value` `` for remote text shown as a code span.
321    fn code(&mut self, label: &str, value: &str) {
322        self.0.push(format!("- **{label}**: `{}`", MdCode(value)));
323    }
324
325    /// [`Self::code`], skipped when the field is absent.
326    fn code_opt(&mut self, label: &str, value: &Option<String>) {
327        if let Some(value) = value {
328            self.code(label, value);
329        }
330    }
331
332    /// `` - **label**: `a`, `b` `` (see [`code_list`]); nothing when empty.
333    fn code_list(&mut self, label: &str, items: &[String]) {
334        if !items.is_empty() {
335            self.raw(label, code_list(items));
336        }
337    }
338
339    /// `` - **label**: `YYYY-MM-DD` ``, skipped when absent.
340    fn date(&mut self, label: &str, date: Option<DateTime<Utc>>) {
341        if let Some(date) = date {
342            self.0
343                .push(format!("- **{label}**: `{}`", date.format("%Y-%m-%d")));
344        }
345    }
346
347    /// `` - **Expires**: `YYYY-MM-DD` (expires in N days) `` (see
348    /// [`expiry`]), skipped when absent.
349    fn expires(&mut self, date: Option<DateTime<Utc>>) {
350        if let Some(date) = date {
351            self.raw("Expires", expiry(date, days_until(date)));
352        }
353    }
354
355    /// One `### <role> Contact` subsection with a bullet per populated
356    /// field (the email as a code span); nothing for an empty contact.
357    ///
358    /// Push these only *after* every domain-level bullet: a `###` heading
359    /// scopes everything below it, so a Created/Expires/Nameservers bullet
360    /// emitted after a contact section renders as part of that contact.
361    fn contact(&mut self, role: &str, c: Contact<'_>) {
362        if c.is_empty() {
363            return;
364        }
365        self.0
366            .extend([String::new(), format!("### {role} Contact"), String::new()]);
367        for (label, field) in c.fields() {
368            match label {
369                "Email" => self.code_opt(label, field),
370                _ => self.opt(label, field),
371            }
372        }
373    }
374
375    /// [`Self::contact`] for each of [`contact::ROLES`].
376    fn contacts(&mut self, contacts: [Contact<'_>; 3]) {
377        for (role, c) in contact::ROLES.into_iter().zip(contacts) {
378            self.contact(role, c);
379        }
380    }
381}
382
383with_report_methods!(impl_forwarding!(MarkdownFormatter;));
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    // --- MdSafe sanitization tests -----------------------------------------
390
391    fn md(s: &str) -> String {
392        format!("{}", MdSafe(s))
393    }
394
395    #[test]
396    fn test_mdsafe_strips_ansi_escape() {
397        assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
398    }
399
400    #[test]
401    fn test_mdsafe_collapses_newlines_cr_tab() {
402        assert_eq!(md("a\nb"), "a b");
403        assert_eq!(md("a\rb"), "a b");
404        assert_eq!(md("a\tb"), "a b");
405        // CRLF becomes two spaces (each replaced individually); that's fine —
406        // the goal is to prevent breaking the line, not perfect whitespace.
407        assert_eq!(md("a\r\nb"), "a  b");
408    }
409
410    #[test]
411    fn test_mdsafe_neutralizes_backticks() {
412        assert_eq!(md("`bad`"), "'bad'");
413        assert_eq!(md("a `b` c"), "a 'b' c");
414    }
415
416    #[test]
417    fn test_mdsafe_escapes_table_pipe() {
418        // A bare `|` from attacker-controlled data (DNS TXT, cert subject,
419        // WHOIS contact) breaks out of a Markdown table cell / fabricates
420        // columns. GFM's cell escape is backslash-pipe, a literal `|` both
421        // inside and outside tables.
422        assert_eq!(md("a|b"), "a\\|b");
423        assert_eq!(md("x | y | z"), "x \\| y \\| z");
424    }
425
426    #[test]
427    fn test_mdsafe_drops_other_control_chars() {
428        // NUL and DEL must vanish entirely.
429        assert_eq!(md("a\0b\x7fc"), "abc");
430    }
431
432    #[test]
433    fn test_mdsafe_preserves_unicode() {
434        assert_eq!(md("café — résumé"), "café — résumé");
435    }
436
437    #[test]
438    fn test_mdsafe_lone_esc_does_not_swallow_following_text() {
439        // A bare ESC not followed by a real ANSI introducer must drop only the
440        // ESC, not the following characters. `X` is not a CSI/OSC introducer, so
441        // "hello" must survive — and the `|` is still escaped for table safety.
442        assert_eq!(md("\x1bXhello|world"), "Xhello\\|world");
443    }
444
445    #[test]
446    fn test_mdsafe_truncated_escape_does_not_swallow_newline_or_text() {
447        // ESC + introducer `[` with no terminator before a newline: the scan
448        // must bail at the newline (re-emitting it as a space) rather than
449        // consuming "Ignore" while hunting for a terminator that never comes.
450        assert_eq!(md("\x1b[\nIgnore"), " Ignore");
451    }
452
453    #[test]
454    fn test_mdsafe_still_strips_wellformed_ansi() {
455        // Regression: a complete CSI color sequence is still fully removed.
456        assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
457        // OSC terminated by BEL is still removed.
458        assert_eq!(md("\x1b]0;title\x07rest"), "rest");
459    }
460
461    #[test]
462    fn test_mdsafe_esc_then_pipe_still_escapes_pipe() {
463        // After dropping a lone ESC, a following structural `|` must still be
464        // escaped — the ANSI handling must not bypass the table-cell defense.
465        assert_eq!(md("\x1b|col"), "\\|col");
466    }
467
468    #[test]
469    fn test_mdsafe_neutralizes_links_images_and_html() {
470        // Attacker-controlled WHOIS/RDAP text must not render as a live image
471        // (tracking pixel), link (phishing), autolink, or raw HTML wherever
472        // the markdown is rendered.
473        assert_eq!(
474            md("![x](https://attacker.example/p.png)"),
475            "!\\[x\\](https://attacker.example/p.png)"
476        );
477        assert_eq!(
478            md("[Verify](https://phish.example)"),
479            "\\[Verify\\](https://phish.example)"
480        );
481        assert_eq!(
482            md("<img src=https://attacker.example/p.png>"),
483            "\\<img src=https://attacker.example/p.png\\>"
484        );
485        assert_eq!(md("<https://phish.example>"), "\\<https://phish.example\\>");
486        // Parentheses alone carry no link syntax and stay unescaped.
487        assert_eq!(md("Example (Holdings) LLC"), "Example (Holdings) LLC");
488    }
489
490    #[test]
491    fn test_mdsafe_escapes_backslash_so_it_cannot_cancel_an_escape() {
492        // A value that pre-escapes its own brackets used to come out as
493        // `\\[Verify\\](…)`: each `\\` renders one backslash and the brackets
494        // are live again, i.e. a clickable phishing link.
495        assert_eq!(
496            md("\\[Verify\\](https://phish.example)"),
497            "\\\\\\[Verify\\\\\\](https://phish.example)"
498        );
499        // Same for a pre-escaped pipe (would add a table column) and `<`.
500        assert_eq!(md("a\\|b"), "a\\\\\\|b");
501        assert_eq!(md("\\<img>"), "\\\\\\<img\\>");
502        assert_eq!(md("C:\\path"), "C:\\\\path");
503    }
504
505    fn code(s: &str) -> String {
506        format!("{}", MdCode(s))
507    }
508
509    /// The one line `write` appends through a [`Bullets`] writer.
510    fn bullet(write: impl FnOnce(&mut Bullets<'_>)) -> String {
511        let mut out = Vec::new();
512        write(&mut Bullets(&mut out));
513        assert_eq!(out.len(), 1, "{out:?}");
514        out.remove(0)
515    }
516
517    fn cell(s: &str) -> String {
518        format!("{}", MdCodeCell(s))
519    }
520
521    #[test]
522    fn code_spans_are_not_backslash_escaped() {
523        // Backslash escapes are not processed inside a code span, so the
524        // text-context escaping printed `CN=\<x\>` for a subject `CN=<x>`.
525        assert_eq!(code("CN=<x>"), "CN=<x>");
526        assert_eq!(code("[a](b) \\ c|d"), "[a](b) \\ c|d");
527        // What could close the span or the line is still neutralized.
528        assert_eq!(code("a`b\nc\x1b[31m"), "a'b c");
529    }
530
531    #[test]
532    fn table_cell_code_spans_escape_pipes() {
533        // GFM splits a row on `|` before parsing the span, so a pipe inside
534        // a code span in a cell still needs `\|` …
535        assert_eq!(cell("a|b"), "a\\|b");
536        assert_eq!(cell("CN=<x>"), "CN=<x>");
537        // … and a backslash before it is doubled, or the table would pair
538        // `\\` and split on the now-bare pipe.
539        assert_eq!(cell("a\\|b"), "a\\\\\\|b");
540        assert_eq!(cell("a\\b"), "a\\b");
541        assert_eq!(
542            bullet(|b| b.code("Subject", "CN=<x>")),
543            "- **Subject**: `CN=<x>`"
544        );
545    }
546
547    #[test]
548    fn invisible_bidi_and_zero_width_chars_are_dropped() {
549        // A right-to-left override can make `moc.evil` read as `live.com`.
550        let trojan = "a\u{202E}b\u{2066}c\u{200B}d\u{FEFF}e\u{061C}f\u{200F}g";
551        assert_eq!(md(trojan), "abcdefg");
552        assert_eq!(code(trojan), "abcdefg");
553        assert_eq!(cell(trojan), "abcdefg");
554    }
555
556    #[test]
557    fn contact_iterates_every_field_with_the_email_as_code() {
558        let (name, email, address) = (
559            Some("Jane".to_string()),
560            Some("jane@example.com".to_string()),
561            Some("1 Main St".to_string()),
562        );
563        let contact = Contact {
564            name: &name,
565            email: &email,
566            address: &address,
567            ..Contact::EMPTY
568        };
569        let mut out = Vec::new();
570        Bullets(&mut out).contact("Admin", contact);
571        assert_eq!(
572            out,
573            [
574                "",
575                "### Admin Contact",
576                "",
577                "- **Name**: Jane",
578                "- **Email**: `jane@example.com`",
579                "- **Address**: 1 Main St",
580            ]
581        );
582    }
583
584    #[test]
585    fn expires_bullet_says_expired_days_ago() {
586        let past = Utc::now() - chrono::Duration::days(3) - chrono::Duration::hours(1);
587        let line = bullet(|b| b.expires(Some(past)));
588        assert!(line.ends_with("(expired 3 days ago)"), "got: {line}");
589        let soon = Utc::now() + chrono::Duration::days(10) + chrono::Duration::hours(1);
590        let line = bullet(|b| b.expires(Some(soon)));
591        assert!(line.ends_with("(expires in 10 days)"), "got: {line}");
592    }
593
594    #[test]
595    fn domain_info_renders_registrar_detail_and_lifecycle_fields() {
596        // Mirror of the human-formatter regression: the PR #101
597        // registrar-detail + derived-lifecycle fields must be visible in
598        // `--format markdown`, not just JSON/YAML (2026-07-11 review).
599        let whois = WhoisResponse::parse(
600            "example.com",
601            "whois.test",
602            "Registrar: Example Registrar\n\
603             Creation Date: 2020-01-01T00:00:00Z\n\
604             Registry Expiry Date: 2099-01-01T00:00:00Z\n\
605             Domain Status: clientTransferProhibited\n",
606        );
607        let mut info =
608            crate::domain_info::DomainInfo::from_sources("example.com", None, Some(&whois));
609        info.registrar_abuse_email = Some("abuse@registrar.test".to_string());
610        info.registrar_abuse_phone = Some("+1.5555550100".to_string());
611        info.registrar_iana_id = Some("9999".to_string());
612        info.registrar_url = Some("https://registrar.test".to_string());
613
614        let out = MarkdownFormatter::new().format_domain_info(&info);
615        for needle in [
616            "Registrar Detail",
617            "abuse@registrar.test",
618            "+1.5555550100",
619            "9999",
620            "https://registrar.test",
621            "Days Until Expiry",
622            "Domain Age",
623            "Expiry Status",
624            "clientTransferProhibited",
625        ] {
626            assert!(out.contains(needle), "missing {needle:?} in:\n{out}");
627        }
628    }
629}