1macro_rules! with_report_methods {
33 ($mac:ident!($($prefix:tt)*)) => {
34 $mac! {
35 $($prefix)*
36 format_whois(response: crate::whois::WhoisResponse);
37 format_rdap(response: crate::rdap::RdapResponse);
38 format_dns(records: [crate::dns::DnsRecord]);
39 format_dig(result: crate::dns::DnsQueryResult);
40 format_dns_trace(trace: crate::dns::DnsTrace);
41 format_propagation(result: crate::dns::PropagationResult);
42 format_lookup(result: crate::lookup::LookupResult);
43 format_status(response: crate::status::StatusResponse);
44 format_follow_iteration(iteration: crate::dns::FollowIteration);
45 format_follow(result: crate::dns::FollowResult);
46 format_availability(result: crate::availability::AvailabilityResult);
47 format_dnssec(report: crate::dns::DnssecReport);
48 format_delegation(report: crate::dns::DelegationReport);
49 format_tld(info: crate::tld::TldInfo);
50 format_dns_comparison(comparison: crate::dns::DnsComparison);
51 format_subdomains(result: crate::subdomains::SubdomainResult);
52 format_diff(diff: crate::diff::DomainDiff);
53 format_ssl(report: crate::ssl::SslReport);
54 format_watch(report: crate::watchlist::WatchReport);
55 format_domain_info(info: crate::domain_info::DomainInfo);
56 format_drift(report: crate::drift::DriftReport);
57 format_posture(posture: crate::posture::EmailPosture);
58 format_headers(report: crate::headers::HeaderReport);
59 format_takeover(report: crate::takeover::TakeoverReport);
60 format_caa(policy: crate::caa::CaaPolicy);
61 format_confusables(report: crate::confusables::ConfusableReport);
62 format_subdomain_classification(result: crate::subdomains::SubdomainClassification);
63 format_subdomain_baseline_diff(report: crate::subdomains::SubdomainBaselineDiff);
64 }
65 };
66}
67
68macro_rules! declare_output_formatter {
69 ($($method:ident($arg:ident: $ty:ty);)+) => {
70 pub trait OutputFormatter {
73 $(fn $method(&self, $arg: &$ty) -> String;)+
74 }
75 };
76}
77
78macro_rules! impl_serializing {
81 ($formatter:ty, $render:ident; $($method:ident($arg:ident: $ty:ty);)+) => {
82 impl OutputFormatter for $formatter {
83 $(fn $method(&self, $arg: &$ty) -> String {
84 self.$render($arg)
85 })+
86 }
87 };
88}
89
90macro_rules! impl_forwarding {
97 ($formatter:ty; $($method:ident($arg:ident: $ty:ty);)+) => {
98 #[deny(unconditional_recursion)]
99 impl OutputFormatter for $formatter {
100 $(fn $method(&self, $arg: &$ty) -> String {
101 self.$method($arg)
102 })+
103 }
104 };
105}
106
107mod contact;
108mod diff_table;
109pub mod dig;
110mod grouping;
111mod human;
112mod json;
113mod markdown;
114
115pub use dig::{dig_short, dig_trace_short};
116pub use human::HumanFormatter;
117pub use json::JsonFormatter;
118pub use markdown::MarkdownFormatter;
119
120use chrono::{DateTime, TimeDelta, Utc};
121use serde::{Deserialize, Serialize};
122
123use crate::dns::{AuthenticationTier, PropagationResult, PropagationVerdict};
124use crate::lookup::LookupResult;
125use crate::subdomains::SubdomainStatus;
126use crate::takeover::TakeoverVerdict;
127
128static_regex! {
129 ANSI_ESCAPE_RE = r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[A-Z@-_]";
133}
134
135fn is_invisible_format(c: char) -> bool {
142 matches!(
143 c,
144 '\u{200B}'..='\u{200F}'
145 | '\u{202A}'..='\u{202E}'
146 | '\u{2066}'..='\u{2069}'
147 | '\u{061C}'
148 | '\u{FEFF}'
149 )
150}
151
152pub fn sanitize_line(s: &str) -> String {
161 ANSI_ESCAPE_RE
162 .replace_all(s, "")
163 .chars()
164 .filter_map(|c| match c {
165 '\n' | '\t' | '\u{2028}' | '\u{2029}' => Some(' '),
166 c if c.is_control() || is_invisible_format(c) => None,
167 c => Some(c),
168 })
169 .collect()
170}
171
172fn days_until(when: DateTime<Utc>) -> i64 {
176 crate::dates::days_until(when, Utc::now())
177}
178
179fn day_count(days: i64) -> String {
181 if days == 1 {
182 "1 day".to_string()
183 } else {
184 format!("{days} days")
185 }
186}
187
188fn expiry_phrase(days_until: i64) -> String {
191 if days_until < 0 {
192 format!("expired {} ago", day_count(-days_until))
193 } else {
194 format!("expires in {}", day_count(days_until))
195 }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum Emphasis {
202 Good,
204 Neutral,
206 Caution,
208 Bad,
210}
211
212fn expiry_emphasis(days_until: i64) -> Emphasis {
216 match days_until {
217 d if d < 30 => Emphasis::Bad,
218 d if d < 90 => Emphasis::Caution,
219 _ => Emphasis::Good,
220 }
221}
222
223pub fn availability_label(verdict: &str) -> (&'static str, Emphasis) {
227 match verdict {
228 "available" => ("AVAILABLE", Emphasis::Good),
229 "likely_available" => ("MAY BE AVAILABLE", Emphasis::Caution),
230 "registered" => ("REGISTERED", Emphasis::Neutral),
231 "likely_registered" => ("LIKELY REGISTERED", Emphasis::Caution),
232 _ => ("UNKNOWN", Emphasis::Bad),
233 }
234}
235
236fn lookup_source(result: &LookupResult) -> &'static str {
239 match result {
240 LookupResult::Rdap { .. } => "RDAP",
241 LookupResult::Whois {
242 rdap_error: None, ..
243 } => "WHOIS",
244 LookupResult::Whois { .. }
245 | LookupResult::Available {
246 whois_data: Some(_),
247 ..
248 } => "WHOIS (RDAP unavailable)",
249 LookupResult::Available { .. } => "availability check (RDAP and WHOIS failed)",
250 }
251}
252
253pub(crate) fn dns_delegation_verdict(ns: &crate::DnsNameservers) -> String {
257 if ns.in_sync {
258 return "matches WHOIS".to_string();
259 }
260 let mut parts = Vec::new();
261 if !ns.missing_from_whois.is_empty() {
262 parts.push(format!(
263 "delegated but not in WHOIS: {}",
264 ns.missing_from_whois.join(", ")
265 ));
266 }
267 if !ns.missing_from_dns.is_empty() {
268 parts.push(format!(
269 "in WHOIS but not delegated: {}",
270 ns.missing_from_dns.join(", ")
271 ));
272 }
273 format!("differs from WHOIS ({})", parts.join("; "))
274}
275
276fn format_duration(duration: TimeDelta) -> String {
278 let total_secs = duration.num_seconds();
279 if total_secs < 60 {
280 format!("{total_secs}s")
281 } else if total_secs < 3600 {
282 format!("{}m {}s", total_secs / 60, total_secs % 60)
283 } else {
284 format!("{}h {}m", total_secs / 3600, (total_secs % 3600) / 60)
285 }
286}
287
288const DNSSEC_NOTE: &str = "Note: DNS responses are not DNSSEC-validated";
292
293fn dnssec_depth(tier: AuthenticationTier) -> (&'static str, Option<&'static str>) {
297 match tier {
298 AuthenticationTier::Unsigned => ("unsigned (no DNSSEC records)", None),
299 AuthenticationTier::DigestOnly => (
300 "digest-only (DS↔DNSKEY consistency)",
301 Some(
302 "Note: reflects DS/DNSKEY digest consistency only — RRSIG signatures, \
303 validity periods, and the chain to the root are NOT cryptographically \
304 verified.",
305 ),
306 ),
307 }
308}
309
310fn propagation_detail(result: &PropagationResult) -> String {
313 let responding = result.servers_responding;
314 match result.verdict() {
315 PropagationVerdict::NoAnswer => {
316 format!("none of the {} servers answered", result.servers_checked)
317 }
318 PropagationVerdict::Full if responding == 1 => {
319 "the one responding server answered".to_string()
320 }
321 PropagationVerdict::Full => format!("all {responding} responding servers agree"),
322 _ => format!(
323 "{} of {responding} responding servers agree",
324 result.servers_agreeing()
325 ),
326 }
327}
328
329pub fn takeover_label(verdict: TakeoverVerdict) -> &'static str {
332 match verdict {
333 TakeoverVerdict::Vulnerable => "VULNERABLE",
334 TakeoverVerdict::Potential => "potential",
335 TakeoverVerdict::Inconclusive => "inconclusive",
336 TakeoverVerdict::Safe => "safe",
337 }
338}
339
340fn subdomain_status_label(status: SubdomainStatus) -> &'static str {
342 match status {
343 SubdomainStatus::Live => "live",
344 SubdomainStatus::Dead => "dead",
345 SubdomainStatus::Wildcard => "wildcard",
346 SubdomainStatus::Unknown => "unknown",
347 }
348}
349
350pub fn propagation_difference(inc: &crate::dns::Inconsistency, empty_label: &str) -> String {
356 const INLINE_VALUES: usize = 6;
357 if inc.values.is_empty() {
358 return format!("no records ({empty_label})");
359 }
360 if inc.values.len() + inc.consensus.len() <= INLINE_VALUES {
361 return inc.values.join(", ");
362 }
363 let mut parts = Vec::new();
364 let missing = inc.missing_count();
365 if missing > 0 {
366 parts.push(format!("missing {missing} of {}", inc.consensus.len()));
367 }
368 let extra = inc.extra_values();
369 if !extra.is_empty() {
370 parts.push(format!("extra: {}", extra.join(", ")));
371 }
372 parts.join("; ")
373}
374
375const GEO_NOTE: &str = "Note: answers vary by resolver location, typical of GeoDNS/CDN \
378 names rather than a propagation delay";
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
381#[serde(rename_all = "lowercase")]
382pub enum OutputFormat {
383 #[default]
384 Human,
385 Json,
386 Yaml,
387 Markdown,
388}
389
390impl std::str::FromStr for OutputFormat {
391 type Err = String;
392
393 fn from_str(s: &str) -> Result<Self, Self::Err> {
394 match s.to_lowercase().as_str() {
395 "human" | "text" | "pretty" => Ok(OutputFormat::Human),
396 "json" => Ok(OutputFormat::Json),
397 "yaml" | "yml" => Ok(OutputFormat::Yaml),
398 "markdown" | "md" => Ok(OutputFormat::Markdown),
399 _ => Err(format!(
400 "Unknown output format: {}. Use: human, json, yaml, markdown",
401 s
402 )),
403 }
404 }
405}
406
407with_report_methods!(declare_output_formatter!());
408
409#[derive(Default)]
411pub struct YamlFormatter;
412
413impl YamlFormatter {
414 pub fn new() -> Self {
415 Self
416 }
417
418 pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
420 match serde_json::to_value(value) {
422 Ok(v) => format_as_yaml(&v, 0),
423 Err(e) => format!("error: {}", yaml_scalar(&e.to_string())),
424 }
425 }
426}
427
428with_report_methods!(impl_serializing!(YamlFormatter, to_yaml_value;));
429
430fn yaml_needs_quoting(s: &str) -> bool {
449 if s.is_empty() || s != s.trim() {
450 return true;
451 }
452 if s.chars()
453 .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
454 {
455 return true;
456 }
457 if matches!(
458 s.to_ascii_lowercase().as_str(),
459 "null" | "~" | "true" | "false" | "yes" | "no" | "y" | "n" | "on" | "off" | "=" | "<<"
460 ) {
461 return true;
462 }
463 if let Some(first) = s.chars().next() {
464 if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
466 return true;
467 }
468 if first.is_ascii_digit() || first == '+' || first == '.' {
470 return true;
471 }
472 }
473 s.contains(": ") || s.ends_with(':') || s.contains(" #")
474}
475
476fn yaml_quote(s: &str) -> String {
480 let mut out = String::with_capacity(s.len() + 2);
481 out.push('"');
482 for c in s.chars() {
483 match c {
484 '"' => out.push_str("\\\""),
485 '\\' => out.push_str("\\\\"),
486 '\n' => out.push_str("\\n"),
487 '\t' => out.push_str("\\t"),
488 '\r' => out.push_str("\\r"),
489 '\0' => out.push_str("\\0"),
490 '\u{0085}' => out.push_str("\\N"),
493 '\u{2028}' => out.push_str("\\L"),
494 '\u{2029}' => out.push_str("\\P"),
495 c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
497 c => out.push(c),
498 }
499 }
500 out.push('"');
501 out
502}
503
504fn yaml_scalar(s: &str) -> String {
506 if yaml_needs_quoting(s) {
507 yaml_quote(s)
508 } else {
509 s.to_string()
510 }
511}
512
513fn is_yaml_block(value: &serde_json::Value) -> bool {
516 match value {
517 serde_json::Value::Array(arr) => !arr.is_empty(),
518 serde_json::Value::Object(map) => !map.is_empty(),
519 _ => false,
520 }
521}
522
523fn format_as_yaml(value: &serde_json::Value, indent: usize) -> String {
530 let prefix = " ".repeat(indent);
531 match value {
532 serde_json::Value::Null => "null".to_string(),
533 serde_json::Value::Bool(b) => b.to_string(),
534 serde_json::Value::Number(n) => n.to_string(),
535 serde_json::Value::String(s) => yaml_scalar(s),
536 serde_json::Value::Array(arr) if arr.is_empty() => "[]".to_string(),
537 serde_json::Value::Object(map) if map.is_empty() => "{}".to_string(),
538 serde_json::Value::Array(arr) => {
539 let item_prefix = " ".repeat(indent + 1);
540 arr.iter()
541 .map(|item| {
542 let rendered = format_as_yaml(item, indent + 1);
543 let first_line = rendered.strip_prefix(&item_prefix).unwrap_or(&rendered);
544 format!("{prefix}- {first_line}")
545 })
546 .collect::<Vec<_>>()
547 .join("\n")
548 }
549 serde_json::Value::Object(map) => map
550 .iter()
551 .map(|(key, val)| {
552 let key = yaml_scalar(key);
555 if is_yaml_block(val) {
556 format!("{prefix}{key}:\n{}", format_as_yaml(val, indent + 1))
557 } else {
558 format!("{prefix}{key}: {}", format_as_yaml(val, indent))
559 }
560 })
561 .collect::<Vec<_>>()
562 .join("\n"),
563 }
564}
565
566pub fn get_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
567 match format {
568 OutputFormat::Human => Box::new(HumanFormatter::new()),
569 OutputFormat::Json => Box::new(JsonFormatter::new()),
570 OutputFormat::Yaml => Box::new(YamlFormatter::new()),
571 OutputFormat::Markdown => Box::new(MarkdownFormatter::new()),
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn test_output_format_from_str() {
581 assert_eq!(
582 "human".parse::<OutputFormat>().unwrap(),
583 OutputFormat::Human
584 );
585 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
586 assert_eq!("yaml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
587 assert_eq!("yml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
588 assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Human);
589 assert_eq!(
590 "pretty".parse::<OutputFormat>().unwrap(),
591 OutputFormat::Human
592 );
593 assert!("invalid".parse::<OutputFormat>().is_err());
594 }
595
596 #[test]
597 fn test_output_format_default() {
598 assert_eq!(OutputFormat::default(), OutputFormat::Human);
599 }
600
601 #[test]
602 fn test_get_formatter_returns_correct_type() {
603 let _ = get_formatter(OutputFormat::Human);
605 let _ = get_formatter(OutputFormat::Json);
606 let _ = get_formatter(OutputFormat::Yaml);
607 }
608
609 #[test]
610 fn test_yaml_formatter_basic() {
611 let formatter = YamlFormatter::new();
612 let status = crate::status::StatusResponse::new("example.com".to_string());
613 let output = formatter.format_status(&status);
614 assert!(output.contains("example.com"));
615 assert!(output.contains("domain"));
616 }
617
618 #[test]
619 fn test_format_as_yaml_primitives() {
620 assert_eq!(format_as_yaml(&serde_json::json!(null), 0), "null");
621 assert_eq!(format_as_yaml(&serde_json::json!(true), 0), "true");
622 assert_eq!(format_as_yaml(&serde_json::json!(42), 0), "42");
623 assert_eq!(format_as_yaml(&serde_json::json!("hello"), 0), "hello");
624 }
625
626 #[test]
627 fn test_format_as_yaml_array() {
628 let output = format_as_yaml(&serde_json::json!([1, 2, 3]), 0);
629 assert!(output.contains("- 1"));
630 assert!(output.contains("- 2"));
631 assert!(output.contains("- 3"));
632 }
633
634 #[test]
635 fn yaml_is_clean_block_style_without_blank_or_trailing_space_lines() {
636 use serde_json::json;
637 let doc = format_as_yaml(
640 &json!([
641 {"name": "a", "ttl": 300},
642 {"name": "b", "tags": ["x", "z"], "empty": [], "meta": {"k": "v"}},
643 [1, 2]
644 ]),
645 0,
646 );
647 let expected = "\
648- name: a
649 ttl: 300
650- empty: []
651 meta:
652 k: v
653 name: b
654 tags:
655 - x
656 - z
657- - 1
658 - 2";
659 assert_eq!(doc, expected);
660 for line in doc.lines() {
661 assert!(!line.ends_with(' '), "trailing space: {line:?}");
662 }
663
664 let doc = format_as_yaml(&json!({"domain": "x", "records": [{"a": 1}]}), 0);
665 assert_eq!(doc, "domain: x\nrecords:\n - a: 1");
666 }
667
668 #[test]
669 fn sanitize_line_strips_controls_and_folds_line_breaks() {
670 assert_eq!(sanitize_line("a\rb\x08c\x07d\u{009b}e"), "abcde");
674 assert_eq!(
675 sanitize_line("line1\nline2\tcol\u{2028}x"),
676 "line1 line2 col x"
677 );
678 assert_eq!(
680 sanitize_line("\x1b]0;malicious title\x1b\\visible"),
681 "visible"
682 );
683 assert_eq!(sanitize_line("\x1b[31mred\x1b[0m"), "red");
684 }
685
686 #[test]
687 fn sanitize_line_drops_bidi_and_zero_width_format_chars() {
688 let trojan = "a\u{202E}b\u{2066}c\u{200B}d\u{FEFF}e\u{061C}f\u{200E}g\u{2069}";
691 assert_eq!(sanitize_line(trojan), "abcdefg");
692 assert_eq!(sanitize_line("café — ü"), "café — ü");
694 }
695
696 #[test]
697 fn expiry_phrase_is_one_wording_for_past_and_future() {
698 assert_eq!(expiry_phrase(-5), "expired 5 days ago");
699 assert_eq!(expiry_phrase(-1), "expired 1 day ago");
700 assert_eq!(expiry_phrase(0), "expires in 0 days");
701 assert_eq!(expiry_phrase(1), "expires in 1 day");
702 assert_eq!(expiry_phrase(29), "expires in 29 days");
703 assert_eq!(expiry_emphasis(-1), Emphasis::Bad);
704 assert_eq!(expiry_emphasis(29), Emphasis::Bad);
705 assert_eq!(expiry_emphasis(30), Emphasis::Caution);
706 assert_eq!(expiry_emphasis(90), Emphasis::Good);
707 }
708
709 #[test]
710 fn availability_label_covers_every_verdict_and_tolerates_others() {
711 assert_eq!(availability_label("available").0, "AVAILABLE");
712 assert_eq!(availability_label("likely_available").0, "MAY BE AVAILABLE");
713 assert_eq!(availability_label("registered").0, "REGISTERED");
714 assert_eq!(
715 availability_label("likely_registered").0,
716 "LIKELY REGISTERED"
717 );
718 assert_eq!(availability_label("unknown"), ("UNKNOWN", Emphasis::Bad));
719 assert_eq!(availability_label("whatever"), ("UNKNOWN", Emphasis::Bad));
720 }
721
722 #[test]
723 fn propagation_detail_names_a_lone_responder() {
724 use crate::dns::{
725 ConsensusValue, DnsRecord, DnsServer, DnsStatus, PropagationServerResult, RecordData,
726 RecordType,
727 };
728 let answered = PropagationServerResult {
729 server: DnsServer::new("Google", "8.8.8.8", "NA", "Google"),
730 records: vec![DnsRecord {
731 name: "example.com".into(),
732 record_type: RecordType::A,
733 ttl: 300,
734 data: RecordData::A {
735 address: "1.2.3.4".into(),
736 },
737 }],
738 response_time_ms: 12,
739 success: true,
740 error: None,
741 status: Some(DnsStatus::NoError),
742 };
743 let result = PropagationResult {
744 domain: "example.com".into(),
745 record_type: RecordType::A,
746 servers_checked: 2,
747 servers_responding: 1,
748 propagation_percentage: 100.0,
749 results: vec![answered],
750 consensus_values: vec![ConsensusValue::new(RecordType::A, "1.2.3.4")],
751 inconsistencies: vec![],
752 unreachable_servers: vec![],
753 dnssec_validated: false,
754 nameserver_details: None,
755 };
756 assert_eq!(
757 propagation_detail(&result),
758 "the one responding server answered"
759 );
760 let md = MarkdownFormatter::new().format_propagation(&result);
762 assert!(md.contains("the one responding server answered"), "{md}");
763 }
764
765 #[test]
766 fn dnssec_depth_notes_what_each_tier_does_not_verify() {
767 assert_eq!(dnssec_depth(AuthenticationTier::Unsigned).1, None);
768 let digest = dnssec_depth(AuthenticationTier::DigestOnly).1.unwrap();
769 assert!(digest.contains("RRSIG signatures, validity periods"));
770 }
771
772 #[test]
773 fn test_format_as_yaml_empty_collections() {
774 assert_eq!(format_as_yaml(&serde_json::json!([]), 0), "[]");
775 assert_eq!(format_as_yaml(&serde_json::json!({}), 0), "{}");
776 }
777
778 #[test]
781 fn yaml_scalar_quoting_hardened() {
782 use serde_json::json;
783 let y = |s: &str| format_as_yaml(&json!(s), 0);
784
785 assert_eq!(y("No"), "\"No\"");
787 assert_eq!(y("true"), "\"true\"");
788 assert_eq!(y("null"), "\"null\"");
789 assert_eq!(y("~"), "\"~\"");
790
791 assert_eq!(y(""), "\"\"");
793 assert_eq!(y(" leading"), "\" leading\"");
794 assert_eq!(y("trailing "), "\"trailing \"");
795
796 for s in [
798 "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&", "|pipe", ">gt",
799 "%pct", "`tick", "?q", ",comma",
800 ] {
801 assert_eq!(
802 y(s),
803 format!("\"{s}\""),
804 "must quote leading-indicator {s:?}"
805 );
806 }
807
808 assert_eq!(y("key: value"), "\"key: value\"");
810
811 assert_eq!(y("a\tb"), "\"a\\tb\"");
813 assert_eq!(y("a\rb"), "\"a\\rb\"");
814 assert_eq!(y("x\x1b[31m"), "\"x\\x1b[31m\"");
815
816 assert_eq!(y("plain-value"), "plain-value");
818 assert_eq!(y("has internal spaces"), "has internal spaces");
819 assert_eq!(y("ns1.example.com"), "ns1.example.com");
820 }
821
822 #[test]
823 fn yaml_quotes_strings_a_resolver_would_read_as_non_strings() {
824 use serde_json::json;
825 let y = |s: &str| format_as_yaml(&json!(s), 0);
826
827 for s in [
828 "+1.5555550100", "292", "0123", "1:20", "2024-01-15", "2024-01-15T04:00:00Z", ".inf",
835 ".NaN",
836 "=", "<<", "y", "N",
840 ] {
841 assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
842 }
843
844 assert_eq!(y("ns1.example.com"), "ns1.example.com");
846 assert_eq!(y("yes-but-longer"), "yes-but-longer");
847 assert_eq!(y("a=b"), "a=b");
848 assert_eq!(format_as_yaml(&json!(292), 0), "292");
849 assert_eq!(format_as_yaml(&json!(1.5), 0), "1.5");
850 }
851
852 #[test]
853 fn yaml_escapes_unicode_line_breaks() {
854 use serde_json::json;
855 let y = |s: &str| format_as_yaml(&json!(s), 0);
860 assert_eq!(y("a\u{2028}b"), "\"a\\Lb\"");
861 assert_eq!(y("a\u{2029}b"), "\"a\\Pb\"");
862 assert_eq!(y("a\u{0085}b"), "\"a\\Nb\"");
863
864 let doc = format_as_yaml(&json!({ "k\u{2028}ey": "v" }), 0);
865 assert_eq!(doc, "\"k\\Ley\": v");
866 }
867
868 #[test]
869 fn yaml_object_keys_with_significant_chars_are_quoted() {
870 let doc = format_as_yaml(&serde_json::json!({ "a\nb": "v" }), 0);
873 assert!(
874 doc.contains("\"a\\nb\""),
875 "malicious key must be quoted/escaped: {doc:?}"
876 );
877 let doc = format_as_yaml(&serde_json::json!({ "domain": "x" }), 0);
879 assert!(
880 doc.starts_with("domain:"),
881 "plain key must not be quoted: {doc:?}"
882 );
883 }
884}