seer_core/output/markdown/
mod.rs1use std::fmt::{self, Write as _};
2
3use super::OutputFormatter;
4
5pub(super) use super::grouping::render_grouped;
7pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
8pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
9pub(super) use crate::lookup::LookupResult;
10pub(super) use crate::rdap::RdapResponse;
11pub(super) use crate::status::StatusResponse;
12pub(super) use crate::whois::WhoisResponse;
13
14mod diff;
15mod dns;
16mod domain_info;
17mod lookup;
18mod propagation;
19mod rdap;
20mod status;
21mod whois;
22
23pub(super) struct MdSafe<'a>(pub &'a str);
32
33impl fmt::Display for MdSafe<'_> {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 let mut iter = self.0.chars().peekable();
36 while let Some(c) = iter.next() {
37 match c {
38 '\x1b' => consume_escape(&mut iter),
39 '\n' | '\r' | '\t' => f.write_str(" ")?,
40 '`' => f.write_str("'")?,
41 '|' => f.write_str("\\|")?,
46 c if c.is_control() => {}
47 c => f.write_char(c)?,
48 }
49 }
50 Ok(())
51 }
52}
53
54const ANSI_SCAN_CAP: usize = 64;
58
59fn consume_escape(iter: &mut std::iter::Peekable<std::str::Chars<'_>>) {
69 match iter.peek() {
70 Some('[') => {
72 iter.next(); for _ in 0..ANSI_SCAN_CAP {
74 match iter.peek() {
75 Some('\n') | Some('\r') | None => break,
78 Some(&ch) => {
79 iter.next();
80 if matches!(ch as u32, 0x40..=0x7E) {
81 break;
82 }
83 }
84 }
85 }
86 }
87 Some(']') | Some('P') => {
90 iter.next(); for _ in 0..ANSI_SCAN_CAP {
92 match iter.peek() {
93 Some('\n') | Some('\r') | None => break,
94 Some('\x07') => {
95 iter.next();
96 break;
97 }
98 Some('\x1b') => {
99 iter.next();
101 if iter.peek() == Some(&'\\') {
102 iter.next();
103 }
104 break;
105 }
106 Some(_) => {
107 iter.next();
108 }
109 }
110 }
111 }
112 Some('(') | Some(')') | Some('*') | Some('+') => {
116 iter.next(); if !matches!(iter.peek(), Some('\n') | Some('\r') | None) {
118 iter.next();
119 }
120 }
121 _ => {}
124 }
125}
126
127pub struct MarkdownFormatter;
129
130impl Default for MarkdownFormatter {
131 fn default() -> Self {
132 Self::new()
133 }
134}
135
136impl MarkdownFormatter {
137 pub fn new() -> Self {
138 Self
139 }
140
141 fn render_caa_section(&self, caa: &CaaPolicy) -> Vec<String> {
144 let mut out = Vec::new();
145 out.push(String::new());
146 out.push("### CAA Policy".to_string());
147 out.push(String::new());
148
149 if !caa.has_policy {
150 out.push("*No CAA records (any CA may issue)*".to_string());
151 } else {
152 if let Some(ref eff) = caa.effective_domain {
153 out.push(format!("- **Found at**: `{}`", MdSafe(eff)));
154 }
155 out.push(String::new());
156 out.push("| Flags | Tag | Value |".to_string());
157 out.push("| --- | --- | --- |".to_string());
158 for r in &caa.records {
159 out.push(format!(
160 "| {} | `{}` | `{}` |",
161 r.flags,
162 MdSafe(&r.tag),
163 MdSafe(&r.value)
164 ));
165 }
166 }
167
168 if let Some(m) = caa.issuer_match {
169 let rendered = match m {
170 IssuerCaaMatch::NoPolicy => "no policy — any CA permitted",
171 IssuerCaaMatch::Permitted => "issuer permitted by current CAA policy",
172 IssuerCaaMatch::Mismatch => "issuer not in current CAA policy (informational)",
173 IssuerCaaMatch::Indeterminate => "CAA present but no issue/issuewild tags",
174 };
175 out.push(String::new());
176 out.push(format!("- **Issuer vs CAA**: {}", rendered));
177 }
178
179 out.push(String::new());
180 out.push(format!("> **Note:** {}", caa.note));
181 out
182 }
183
184 fn format_rdap_contact(
186 &self,
187 output: &mut Vec<String>,
188 label: &str,
189 contact: &crate::rdap::ContactInfo,
190 ) {
191 if !contact.has_info() {
192 return;
193 }
194 output.push(String::new());
195 output.push(format!("### {}", label));
196 output.push(String::new());
197 if let Some(ref name) = contact.name {
198 output.push(format!("- **Name**: {}", MdSafe(name)));
199 }
200 if let Some(ref org) = contact.organization {
201 output.push(format!("- **Organization**: {}", MdSafe(org)));
202 }
203 if let Some(ref email) = contact.email {
204 output.push(format!("- **Email**: `{}`", MdSafe(email)));
205 }
206 if let Some(ref phone) = contact.phone {
207 output.push(format!("- **Phone**: {}", MdSafe(phone)));
208 }
209 if let Some(ref address) = contact.address {
210 output.push(format!("- **Address**: {}", MdSafe(address)));
211 }
212 if let Some(ref country) = contact.country {
213 output.push(format!("- **Country**: {}", MdSafe(country)));
214 }
215 }
216
217 fn format_whois_contact(
219 &self,
220 output: &mut Vec<String>,
221 label: &str,
222 name: &Option<String>,
223 organization: &Option<String>,
224 email: &Option<String>,
225 phone: &Option<String>,
226 ) {
227 let has_info =
228 name.is_some() || organization.is_some() || email.is_some() || phone.is_some();
229 if !has_info {
230 return;
231 }
232 output.push(String::new());
233 output.push(format!("### {}", label));
234 output.push(String::new());
235 if let Some(ref v) = *name {
236 output.push(format!("- **Name**: {}", MdSafe(v)));
237 }
238 if let Some(ref v) = *organization {
239 output.push(format!("- **Organization**: {}", MdSafe(v)));
240 }
241 if let Some(ref v) = *email {
242 output.push(format!("- **Email**: `{}`", MdSafe(v)));
243 }
244 if let Some(ref v) = *phone {
245 output.push(format!("- **Phone**: {}", MdSafe(v)));
246 }
247 }
248}
249
250impl OutputFormatter for MarkdownFormatter {
254 fn format_whois(&self, response: &WhoisResponse) -> String {
255 self.format_whois(response)
256 }
257 fn format_rdap(&self, response: &RdapResponse) -> String {
258 self.format_rdap(response)
259 }
260 fn format_dns(&self, records: &[DnsRecord]) -> String {
261 self.format_dns(records)
262 }
263 fn format_propagation(&self, result: &PropagationResult) -> String {
264 self.format_propagation(result)
265 }
266 fn format_lookup(&self, result: &LookupResult) -> String {
267 self.format_lookup(result)
268 }
269 fn format_status(&self, response: &StatusResponse) -> String {
270 self.format_status(response)
271 }
272 fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
273 self.format_follow_iteration(iteration)
274 }
275 fn format_follow(&self, result: &FollowResult) -> String {
276 self.format_follow(result)
277 }
278 fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
279 self.format_availability(result)
280 }
281 fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
282 self.format_tld(info)
283 }
284 fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
285 self.format_dnssec(report)
286 }
287 fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
288 self.format_dns_comparison(comparison)
289 }
290 fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
291 self.format_subdomains(result)
292 }
293 fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
294 self.format_diff(diff)
295 }
296 fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
297 self.format_ssl(report)
298 }
299 fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
300 self.format_watch(report)
301 }
302 fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
303 self.format_domain_info(info)
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 fn md(s: &str) -> String {
314 format!("{}", MdSafe(s))
315 }
316
317 #[test]
318 fn test_mdsafe_strips_ansi_escape() {
319 assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
320 }
321
322 #[test]
323 fn test_mdsafe_collapses_newlines_cr_tab() {
324 assert_eq!(md("a\nb"), "a b");
325 assert_eq!(md("a\rb"), "a b");
326 assert_eq!(md("a\tb"), "a b");
327 assert_eq!(md("a\r\nb"), "a b");
330 }
331
332 #[test]
333 fn test_mdsafe_neutralizes_backticks() {
334 assert_eq!(md("`bad`"), "'bad'");
335 assert_eq!(md("a `b` c"), "a 'b' c");
336 }
337
338 #[test]
339 fn test_mdsafe_escapes_table_pipe() {
340 assert_eq!(md("a|b"), "a\\|b");
345 assert_eq!(md("x | y | z"), "x \\| y \\| z");
346 }
347
348 #[test]
349 fn test_mdsafe_drops_other_control_chars() {
350 assert_eq!(md("a\0b\x7fc"), "abc");
352 }
353
354 #[test]
355 fn test_mdsafe_preserves_unicode() {
356 assert_eq!(md("café — résumé"), "café — résumé");
357 }
358
359 #[test]
360 fn test_mdsafe_lone_esc_does_not_swallow_following_text() {
361 assert_eq!(md("\x1bXhello|world"), "Xhello\\|world");
365 }
366
367 #[test]
368 fn test_mdsafe_truncated_escape_does_not_swallow_newline_or_text() {
369 assert_eq!(md("\x1b[\nIgnore"), " Ignore");
373 }
374
375 #[test]
376 fn test_mdsafe_still_strips_wellformed_ansi() {
377 assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
379 assert_eq!(md("\x1b]0;title\x07rest"), "rest");
381 }
382
383 #[test]
384 fn test_mdsafe_esc_then_pipe_still_escapes_pipe() {
385 assert_eq!(md("\x1b|col"), "\\|col");
388 }
389}