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 security;
21mod status;
22mod whois;
23
24pub(super) struct MdSafe<'a>(pub &'a str);
33
34impl fmt::Display for MdSafe<'_> {
35 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36 let mut iter = self.0.chars().peekable();
37 while let Some(c) = iter.next() {
38 match c {
39 '\x1b' => consume_escape(&mut iter),
40 '\n' | '\r' | '\t' => f.write_str(" ")?,
41 '`' => f.write_str("'")?,
42 '|' => f.write_str("\\|")?,
47 c if c.is_control() => {}
48 c => f.write_char(c)?,
49 }
50 }
51 Ok(())
52 }
53}
54
55const ANSI_SCAN_CAP: usize = 64;
59
60fn consume_escape(iter: &mut std::iter::Peekable<std::str::Chars<'_>>) {
70 match iter.peek() {
71 Some('[') => {
73 iter.next(); for _ in 0..ANSI_SCAN_CAP {
75 match iter.peek() {
76 Some('\n') | Some('\r') | None => break,
79 Some(&ch) => {
80 iter.next();
81 if matches!(ch as u32, 0x40..=0x7E) {
82 break;
83 }
84 }
85 }
86 }
87 }
88 Some(']') | Some('P') => {
91 iter.next(); for _ in 0..ANSI_SCAN_CAP {
93 match iter.peek() {
94 Some('\n') | Some('\r') | None => break,
95 Some('\x07') => {
96 iter.next();
97 break;
98 }
99 Some('\x1b') => {
100 iter.next();
102 if iter.peek() == Some(&'\\') {
103 iter.next();
104 }
105 break;
106 }
107 Some(_) => {
108 iter.next();
109 }
110 }
111 }
112 }
113 Some('(') | Some(')') | Some('*') | Some('+') => {
117 iter.next(); if !matches!(iter.peek(), Some('\n') | Some('\r') | None) {
119 iter.next();
120 }
121 }
122 _ => {}
125 }
126}
127
128pub struct MarkdownFormatter;
130
131impl Default for MarkdownFormatter {
132 fn default() -> Self {
133 Self::new()
134 }
135}
136
137impl MarkdownFormatter {
138 pub fn new() -> Self {
139 Self
140 }
141
142 fn render_caa_section(&self, caa: &CaaPolicy) -> Vec<String> {
145 let mut out = Vec::new();
146 out.push(String::new());
147 out.push("### CAA Policy".to_string());
148 out.push(String::new());
149
150 if !caa.has_policy {
151 out.push("*No CAA records (any CA may issue)*".to_string());
152 } else {
153 if let Some(ref eff) = caa.effective_domain {
154 out.push(format!("- **Found at**: `{}`", MdSafe(eff)));
155 }
156 out.push(String::new());
157 out.push("| Flags | Tag | Value |".to_string());
158 out.push("| --- | --- | --- |".to_string());
159 for r in &caa.records {
160 out.push(format!(
161 "| {} | `{}` | `{}` |",
162 r.flags,
163 MdSafe(&r.tag),
164 MdSafe(&r.value)
165 ));
166 }
167 }
168
169 if let Some(m) = caa.issuer_match {
170 let rendered = match m {
171 IssuerCaaMatch::NoPolicy => "no policy — any CA permitted",
172 IssuerCaaMatch::Permitted => "issuer permitted by current CAA policy",
173 IssuerCaaMatch::Mismatch => "issuer not in current CAA policy (informational)",
174 IssuerCaaMatch::Indeterminate => "CAA present but no issue/issuewild tags",
175 };
176 out.push(String::new());
177 out.push(format!("- **Issuer vs CAA**: {}", rendered));
178 }
179
180 out.push(String::new());
181 out.push(format!("> **Note:** {}", caa.note));
182 out
183 }
184
185 fn format_rdap_contact(
187 &self,
188 output: &mut Vec<String>,
189 label: &str,
190 contact: &crate::rdap::ContactInfo,
191 ) {
192 if !contact.has_info() {
193 return;
194 }
195 output.push(String::new());
196 output.push(format!("### {}", label));
197 output.push(String::new());
198 if let Some(ref name) = contact.name {
199 output.push(format!("- **Name**: {}", MdSafe(name)));
200 }
201 if let Some(ref org) = contact.organization {
202 output.push(format!("- **Organization**: {}", MdSafe(org)));
203 }
204 if let Some(ref email) = contact.email {
205 output.push(format!("- **Email**: `{}`", MdSafe(email)));
206 }
207 if let Some(ref phone) = contact.phone {
208 output.push(format!("- **Phone**: {}", MdSafe(phone)));
209 }
210 if let Some(ref address) = contact.address {
211 output.push(format!("- **Address**: {}", MdSafe(address)));
212 }
213 if let Some(ref country) = contact.country {
214 output.push(format!("- **Country**: {}", MdSafe(country)));
215 }
216 }
217
218 fn format_whois_contact(
220 &self,
221 output: &mut Vec<String>,
222 label: &str,
223 name: &Option<String>,
224 organization: &Option<String>,
225 email: &Option<String>,
226 phone: &Option<String>,
227 ) {
228 let has_info =
229 name.is_some() || organization.is_some() || email.is_some() || phone.is_some();
230 if !has_info {
231 return;
232 }
233 output.push(String::new());
234 output.push(format!("### {}", label));
235 output.push(String::new());
236 if let Some(ref v) = *name {
237 output.push(format!("- **Name**: {}", MdSafe(v)));
238 }
239 if let Some(ref v) = *organization {
240 output.push(format!("- **Organization**: {}", MdSafe(v)));
241 }
242 if let Some(ref v) = *email {
243 output.push(format!("- **Email**: `{}`", MdSafe(v)));
244 }
245 if let Some(ref v) = *phone {
246 output.push(format!("- **Phone**: {}", MdSafe(v)));
247 }
248 }
249}
250
251impl OutputFormatter for MarkdownFormatter {
255 fn format_whois(&self, response: &WhoisResponse) -> String {
256 self.format_whois(response)
257 }
258 fn format_rdap(&self, response: &RdapResponse) -> String {
259 self.format_rdap(response)
260 }
261 fn format_dns(&self, records: &[DnsRecord]) -> String {
262 self.format_dns(records)
263 }
264 fn format_propagation(&self, result: &PropagationResult) -> String {
265 self.format_propagation(result)
266 }
267 fn format_lookup(&self, result: &LookupResult) -> String {
268 self.format_lookup(result)
269 }
270 fn format_status(&self, response: &StatusResponse) -> String {
271 self.format_status(response)
272 }
273 fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
274 self.format_follow_iteration(iteration)
275 }
276 fn format_follow(&self, result: &FollowResult) -> String {
277 self.format_follow(result)
278 }
279 fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
280 self.format_availability(result)
281 }
282 fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
283 self.format_tld(info)
284 }
285 fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
286 self.format_dnssec(report)
287 }
288 fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
289 self.format_dns_comparison(comparison)
290 }
291 fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
292 self.format_subdomains(result)
293 }
294 fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
295 self.format_diff(diff)
296 }
297 fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
298 self.format_ssl(report)
299 }
300 fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
301 self.format_watch(report)
302 }
303 fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
304 self.format_domain_info(info)
305 }
306 fn format_drift(&self, report: &crate::drift::DriftReport) -> String {
307 self.format_drift(report)
308 }
309 fn format_posture(&self, posture: &crate::posture::EmailPosture) -> String {
310 self.format_posture(posture)
311 }
312 fn format_caa(&self, policy: &CaaPolicy) -> String {
313 self.format_caa(policy)
314 }
315 fn format_confusables(&self, report: &crate::confusables::ConfusableReport) -> String {
316 self.format_confusables(report)
317 }
318 fn format_subdomain_classification(
319 &self,
320 result: &crate::subdomains::SubdomainClassification,
321 ) -> String {
322 self.format_subdomain_classification(result)
323 }
324 fn format_subdomain_baseline_diff(
325 &self,
326 report: &crate::subdomains::SubdomainBaselineDiff,
327 ) -> String {
328 self.format_subdomain_baseline_diff(report)
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 fn md(s: &str) -> String {
339 format!("{}", MdSafe(s))
340 }
341
342 #[test]
343 fn test_mdsafe_strips_ansi_escape() {
344 assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
345 }
346
347 #[test]
348 fn test_mdsafe_collapses_newlines_cr_tab() {
349 assert_eq!(md("a\nb"), "a b");
350 assert_eq!(md("a\rb"), "a b");
351 assert_eq!(md("a\tb"), "a b");
352 assert_eq!(md("a\r\nb"), "a b");
355 }
356
357 #[test]
358 fn test_mdsafe_neutralizes_backticks() {
359 assert_eq!(md("`bad`"), "'bad'");
360 assert_eq!(md("a `b` c"), "a 'b' c");
361 }
362
363 #[test]
364 fn test_mdsafe_escapes_table_pipe() {
365 assert_eq!(md("a|b"), "a\\|b");
370 assert_eq!(md("x | y | z"), "x \\| y \\| z");
371 }
372
373 #[test]
374 fn test_mdsafe_drops_other_control_chars() {
375 assert_eq!(md("a\0b\x7fc"), "abc");
377 }
378
379 #[test]
380 fn test_mdsafe_preserves_unicode() {
381 assert_eq!(md("café — résumé"), "café — résumé");
382 }
383
384 #[test]
385 fn test_mdsafe_lone_esc_does_not_swallow_following_text() {
386 assert_eq!(md("\x1bXhello|world"), "Xhello\\|world");
390 }
391
392 #[test]
393 fn test_mdsafe_truncated_escape_does_not_swallow_newline_or_text() {
394 assert_eq!(md("\x1b[\nIgnore"), " Ignore");
398 }
399
400 #[test]
401 fn test_mdsafe_still_strips_wellformed_ansi() {
402 assert_eq!(md("\x1b[31mfoo\x1b[0m"), "foo");
404 assert_eq!(md("\x1b]0;title\x07rest"), "rest");
406 }
407
408 #[test]
409 fn test_mdsafe_esc_then_pipe_still_escapes_pipe() {
410 assert_eq!(md("\x1b|col"), "\\|col");
413 }
414
415 #[test]
416 fn domain_info_renders_registrar_detail_and_lifecycle_fields() {
417 let whois = crate::whois::WhoisResponse::parse(
421 "example.com",
422 "whois.test",
423 "Registrar: Example Registrar\n\
424 Creation Date: 2020-01-01T00:00:00Z\n\
425 Registry Expiry Date: 2099-01-01T00:00:00Z\n\
426 Domain Status: clientTransferProhibited\n",
427 );
428 let mut info =
429 crate::domain_info::DomainInfo::from_sources("example.com", None, Some(&whois));
430 info.registrar_abuse_email = Some("abuse@registrar.test".to_string());
431 info.registrar_abuse_phone = Some("+1.5555550100".to_string());
432 info.registrar_iana_id = Some("9999".to_string());
433 info.registrar_url = Some("https://registrar.test".to_string());
434
435 let out = MarkdownFormatter::new().format_domain_info(&info);
436 for needle in [
437 "Registrar Detail",
438 "abuse@registrar.test",
439 "+1.5555550100",
440 "9999",
441 "https://registrar.test",
442 "Days Until Expiry",
443 "Domain Age",
444 "Expiry Status",
445 "clientTransferProhibited",
446 ] {
447 assert!(out.contains(needle), "missing {needle:?} in:\n{out}");
448 }
449 }
450}