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