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 if ns.whois_lists_none() {
261 return "WHOIS lists no nameservers".to_string();
262 }
263 let mut parts = Vec::new();
264 if !ns.missing_from_whois.is_empty() {
265 parts.push(format!(
266 "delegated but not in WHOIS: {}",
267 ns.missing_from_whois.join(", ")
268 ));
269 }
270 if !ns.missing_from_dns.is_empty() {
271 parts.push(format!(
272 "in WHOIS but not delegated: {}",
273 ns.missing_from_dns.join(", ")
274 ));
275 }
276 format!("differs from WHOIS ({})", parts.join("; "))
277}
278
279pub(crate) fn dns_delegation_differs(ns: &crate::DnsNameservers) -> bool {
282 !ns.in_sync && !ns.whois_lists_none()
283}
284
285fn format_duration(duration: TimeDelta) -> String {
287 let total_secs = duration.num_seconds();
288 if total_secs < 60 {
289 format!("{total_secs}s")
290 } else if total_secs < 3600 {
291 format!("{}m {}s", total_secs / 60, total_secs % 60)
292 } else {
293 format!("{}h {}m", total_secs / 3600, (total_secs % 3600) / 60)
294 }
295}
296
297const DNSSEC_NOTE: &str = "Note: DNS responses are not DNSSEC-validated";
301
302fn dnssec_depth(tier: AuthenticationTier) -> (&'static str, Option<&'static str>) {
306 match tier {
307 AuthenticationTier::Unsigned => ("unsigned (no DNSSEC records)", None),
308 AuthenticationTier::DigestOnly => (
309 "digest-only (DS↔DNSKEY consistency)",
310 Some(
311 "Note: reflects DS/DNSKEY digest consistency only — RRSIG signatures, \
312 validity periods, and the chain to the root are NOT cryptographically \
313 verified.",
314 ),
315 ),
316 }
317}
318
319fn propagation_detail(result: &PropagationResult) -> String {
322 let responding = result.servers_responding;
323 match result.verdict() {
324 PropagationVerdict::NoAnswer => {
325 format!("none of the {} servers answered", result.servers_checked)
326 }
327 PropagationVerdict::Full if responding == 1 => {
328 "the one responding server answered".to_string()
329 }
330 PropagationVerdict::Full => format!("all {responding} responding servers agree"),
331 _ => format!(
332 "{} of {responding} responding servers agree",
333 result.servers_agreeing()
334 ),
335 }
336}
337
338pub fn takeover_label(verdict: TakeoverVerdict) -> &'static str {
341 match verdict {
342 TakeoverVerdict::Vulnerable => "VULNERABLE",
343 TakeoverVerdict::Potential => "potential",
344 TakeoverVerdict::Inconclusive => "inconclusive",
345 TakeoverVerdict::Safe => "safe",
346 }
347}
348
349fn subdomain_status_label(status: SubdomainStatus) -> &'static str {
351 match status {
352 SubdomainStatus::Live => "live",
353 SubdomainStatus::Dead => "dead",
354 SubdomainStatus::Wildcard => "wildcard",
355 SubdomainStatus::Unknown => "unknown",
356 }
357}
358
359pub fn propagation_difference(inc: &crate::dns::Inconsistency, empty_label: &str) -> String {
365 const INLINE_VALUES: usize = 6;
366 if inc.values.is_empty() {
367 return format!("no records ({empty_label})");
368 }
369 if inc.values.len() + inc.consensus.len() <= INLINE_VALUES {
370 return inc.values.join(", ");
371 }
372 let mut parts = Vec::new();
373 let missing = inc.missing_count();
374 if missing > 0 {
375 parts.push(format!("missing {missing} of {}", inc.consensus.len()));
376 }
377 let extra = inc.extra_values();
378 if !extra.is_empty() {
379 parts.push(format!("extra: {}", extra.join(", ")));
380 }
381 parts.join("; ")
382}
383
384const GEO_NOTE: &str = "Note: answers vary by resolver location, typical of GeoDNS/CDN \
387 names rather than a propagation delay";
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
390#[serde(rename_all = "lowercase")]
391pub enum OutputFormat {
392 #[default]
393 Human,
394 Json,
395 Yaml,
396 Markdown,
397}
398
399impl std::str::FromStr for OutputFormat {
400 type Err = String;
401
402 fn from_str(s: &str) -> Result<Self, Self::Err> {
403 match s.to_lowercase().as_str() {
404 "human" | "text" | "pretty" => Ok(OutputFormat::Human),
405 "json" => Ok(OutputFormat::Json),
406 "yaml" | "yml" => Ok(OutputFormat::Yaml),
407 "markdown" | "md" => Ok(OutputFormat::Markdown),
408 _ => Err(format!(
409 "Unknown output format: {}. Use: human, json, yaml, markdown",
410 s
411 )),
412 }
413 }
414}
415
416with_report_methods!(declare_output_formatter!());
417
418#[derive(Default)]
420pub struct YamlFormatter;
421
422impl YamlFormatter {
423 pub fn new() -> Self {
424 Self
425 }
426
427 pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
429 match serde_json::to_value(value) {
431 Ok(v) => format_as_yaml(&v, 0),
432 Err(e) => format!("error: {}", yaml_scalar(&e.to_string())),
433 }
434 }
435}
436
437with_report_methods!(impl_serializing!(YamlFormatter, to_yaml_value;));
438
439fn yaml_needs_quoting(s: &str) -> bool {
458 if s.is_empty() || s != s.trim() {
459 return true;
460 }
461 if s.chars()
462 .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
463 {
464 return true;
465 }
466 if matches!(
467 s.to_ascii_lowercase().as_str(),
468 "null" | "~" | "true" | "false" | "yes" | "no" | "y" | "n" | "on" | "off" | "=" | "<<"
469 ) {
470 return true;
471 }
472 if let Some(first) = s.chars().next() {
473 if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
475 return true;
476 }
477 if first.is_ascii_digit() || first == '+' || first == '.' {
479 return true;
480 }
481 }
482 s.contains(": ") || s.ends_with(':') || s.contains(" #")
483}
484
485fn yaml_quote(s: &str) -> String {
489 let mut out = String::with_capacity(s.len() + 2);
490 out.push('"');
491 for c in s.chars() {
492 match c {
493 '"' => out.push_str("\\\""),
494 '\\' => out.push_str("\\\\"),
495 '\n' => out.push_str("\\n"),
496 '\t' => out.push_str("\\t"),
497 '\r' => out.push_str("\\r"),
498 '\0' => out.push_str("\\0"),
499 '\u{0085}' => out.push_str("\\N"),
502 '\u{2028}' => out.push_str("\\L"),
503 '\u{2029}' => out.push_str("\\P"),
504 c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
506 c => out.push(c),
507 }
508 }
509 out.push('"');
510 out
511}
512
513fn yaml_scalar(s: &str) -> String {
515 if yaml_needs_quoting(s) {
516 yaml_quote(s)
517 } else {
518 s.to_string()
519 }
520}
521
522fn is_yaml_block(value: &serde_json::Value) -> bool {
525 match value {
526 serde_json::Value::Array(arr) => !arr.is_empty(),
527 serde_json::Value::Object(map) => !map.is_empty(),
528 _ => false,
529 }
530}
531
532fn format_as_yaml(value: &serde_json::Value, indent: usize) -> String {
539 let prefix = " ".repeat(indent);
540 match value {
541 serde_json::Value::Null => "null".to_string(),
542 serde_json::Value::Bool(b) => b.to_string(),
543 serde_json::Value::Number(n) => n.to_string(),
544 serde_json::Value::String(s) => yaml_scalar(s),
545 serde_json::Value::Array(arr) if arr.is_empty() => "[]".to_string(),
546 serde_json::Value::Object(map) if map.is_empty() => "{}".to_string(),
547 serde_json::Value::Array(arr) => {
548 let item_prefix = " ".repeat(indent + 1);
549 arr.iter()
550 .map(|item| {
551 let rendered = format_as_yaml(item, indent + 1);
552 let first_line = rendered.strip_prefix(&item_prefix).unwrap_or(&rendered);
553 format!("{prefix}- {first_line}")
554 })
555 .collect::<Vec<_>>()
556 .join("\n")
557 }
558 serde_json::Value::Object(map) => map
559 .iter()
560 .map(|(key, val)| {
561 let key = yaml_scalar(key);
564 if is_yaml_block(val) {
565 format!("{prefix}{key}:\n{}", format_as_yaml(val, indent + 1))
566 } else {
567 format!("{prefix}{key}: {}", format_as_yaml(val, indent))
568 }
569 })
570 .collect::<Vec<_>>()
571 .join("\n"),
572 }
573}
574
575pub fn get_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
576 match format {
577 OutputFormat::Human => Box::new(HumanFormatter::new()),
578 OutputFormat::Json => Box::new(JsonFormatter::new()),
579 OutputFormat::Yaml => Box::new(YamlFormatter::new()),
580 OutputFormat::Markdown => Box::new(MarkdownFormatter::new()),
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn dns_delegation_with_no_whois_nameservers_is_not_a_mismatch() {
590 let ns = |w: &[&str], d: &[&str]| {
591 crate::DnsNameservers::compare(
592 &w.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
593 d.iter().map(|s| s.to_string()).collect(),
594 )
595 };
596 let none = ns(&[], &["a.ns.test", "b.ns.test"]);
598 assert!(none.whois_lists_none());
599 assert!(!dns_delegation_differs(&none));
600 assert_eq!(dns_delegation_verdict(&none), "WHOIS lists no nameservers");
601 let partial = ns(&["a.ns.test"], &["a.ns.test", "b.ns.test"]);
603 assert!(!partial.whois_lists_none());
604 assert!(dns_delegation_differs(&partial));
605 assert!(dns_delegation_verdict(&partial).starts_with("differs from WHOIS"));
606 let wrong = ns(&["x.ns.test"], &["a.ns.test"]);
608 assert!(!wrong.whois_lists_none());
609 assert!(dns_delegation_differs(&wrong));
610 let empty = ns(&[], &[]);
612 assert!(!empty.whois_lists_none());
613 assert!(!dns_delegation_differs(&empty));
614 }
615
616 #[test]
617 fn test_output_format_from_str() {
618 assert_eq!(
619 "human".parse::<OutputFormat>().unwrap(),
620 OutputFormat::Human
621 );
622 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
623 assert_eq!("yaml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
624 assert_eq!("yml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
625 assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Human);
626 assert_eq!(
627 "pretty".parse::<OutputFormat>().unwrap(),
628 OutputFormat::Human
629 );
630 assert!("invalid".parse::<OutputFormat>().is_err());
631 }
632
633 #[test]
634 fn test_output_format_default() {
635 assert_eq!(OutputFormat::default(), OutputFormat::Human);
636 }
637
638 #[test]
639 fn test_get_formatter_returns_correct_type() {
640 let _ = get_formatter(OutputFormat::Human);
642 let _ = get_formatter(OutputFormat::Json);
643 let _ = get_formatter(OutputFormat::Yaml);
644 }
645
646 #[test]
647 fn test_yaml_formatter_basic() {
648 let formatter = YamlFormatter::new();
649 let status = crate::status::StatusResponse::new("example.com".to_string());
650 let output = formatter.format_status(&status);
651 assert!(output.contains("example.com"));
652 assert!(output.contains("domain"));
653 }
654
655 #[test]
656 fn test_format_as_yaml_primitives() {
657 assert_eq!(format_as_yaml(&serde_json::json!(null), 0), "null");
658 assert_eq!(format_as_yaml(&serde_json::json!(true), 0), "true");
659 assert_eq!(format_as_yaml(&serde_json::json!(42), 0), "42");
660 assert_eq!(format_as_yaml(&serde_json::json!("hello"), 0), "hello");
661 }
662
663 #[test]
664 fn test_format_as_yaml_array() {
665 let output = format_as_yaml(&serde_json::json!([1, 2, 3]), 0);
666 assert!(output.contains("- 1"));
667 assert!(output.contains("- 2"));
668 assert!(output.contains("- 3"));
669 }
670
671 #[test]
672 fn yaml_is_clean_block_style_without_blank_or_trailing_space_lines() {
673 use serde_json::json;
674 let doc = format_as_yaml(
677 &json!([
678 {"name": "a", "ttl": 300},
679 {"name": "b", "tags": ["x", "z"], "empty": [], "meta": {"k": "v"}},
680 [1, 2]
681 ]),
682 0,
683 );
684 let expected = "\
685- name: a
686 ttl: 300
687- empty: []
688 meta:
689 k: v
690 name: b
691 tags:
692 - x
693 - z
694- - 1
695 - 2";
696 assert_eq!(doc, expected);
697 for line in doc.lines() {
698 assert!(!line.ends_with(' '), "trailing space: {line:?}");
699 }
700
701 let doc = format_as_yaml(&json!({"domain": "x", "records": [{"a": 1}]}), 0);
702 assert_eq!(doc, "domain: x\nrecords:\n - a: 1");
703 }
704
705 #[test]
706 fn sanitize_line_strips_controls_and_folds_line_breaks() {
707 assert_eq!(sanitize_line("a\rb\x08c\x07d\u{009b}e"), "abcde");
711 assert_eq!(
712 sanitize_line("line1\nline2\tcol\u{2028}x"),
713 "line1 line2 col x"
714 );
715 assert_eq!(
717 sanitize_line("\x1b]0;malicious title\x1b\\visible"),
718 "visible"
719 );
720 assert_eq!(sanitize_line("\x1b[31mred\x1b[0m"), "red");
721 }
722
723 #[test]
724 fn sanitize_line_drops_bidi_and_zero_width_format_chars() {
725 let trojan = "a\u{202E}b\u{2066}c\u{200B}d\u{FEFF}e\u{061C}f\u{200E}g\u{2069}";
728 assert_eq!(sanitize_line(trojan), "abcdefg");
729 assert_eq!(sanitize_line("café — ü"), "café — ü");
731 }
732
733 #[test]
734 fn expiry_phrase_is_one_wording_for_past_and_future() {
735 assert_eq!(expiry_phrase(-5), "expired 5 days ago");
736 assert_eq!(expiry_phrase(-1), "expired 1 day ago");
737 assert_eq!(expiry_phrase(0), "expires in 0 days");
738 assert_eq!(expiry_phrase(1), "expires in 1 day");
739 assert_eq!(expiry_phrase(29), "expires in 29 days");
740 assert_eq!(expiry_emphasis(-1), Emphasis::Bad);
741 assert_eq!(expiry_emphasis(29), Emphasis::Bad);
742 assert_eq!(expiry_emphasis(30), Emphasis::Caution);
743 assert_eq!(expiry_emphasis(90), Emphasis::Good);
744 }
745
746 #[test]
747 fn availability_label_covers_every_verdict_and_tolerates_others() {
748 assert_eq!(availability_label("available").0, "AVAILABLE");
749 assert_eq!(availability_label("likely_available").0, "MAY BE AVAILABLE");
750 assert_eq!(availability_label("registered").0, "REGISTERED");
751 assert_eq!(
752 availability_label("likely_registered").0,
753 "LIKELY REGISTERED"
754 );
755 assert_eq!(availability_label("unknown"), ("UNKNOWN", Emphasis::Bad));
756 assert_eq!(availability_label("whatever"), ("UNKNOWN", Emphasis::Bad));
757 }
758
759 #[test]
760 fn propagation_detail_names_a_lone_responder() {
761 use crate::dns::{
762 ConsensusValue, DnsRecord, DnsServer, DnsStatus, PropagationServerResult, RecordData,
763 RecordType,
764 };
765 let answered = PropagationServerResult {
766 server: DnsServer::new("Google", "8.8.8.8", "NA", "Google"),
767 records: vec![DnsRecord {
768 name: "example.com".into(),
769 record_type: RecordType::A,
770 ttl: 300,
771 data: RecordData::A {
772 address: "1.2.3.4".into(),
773 },
774 }],
775 response_time_ms: 12,
776 success: true,
777 error: None,
778 status: Some(DnsStatus::NoError),
779 };
780 let result = PropagationResult {
781 domain: "example.com".into(),
782 record_type: RecordType::A,
783 servers_checked: 2,
784 servers_responding: 1,
785 propagation_percentage: 100.0,
786 results: vec![answered],
787 consensus_values: vec![ConsensusValue::new(RecordType::A, "1.2.3.4")],
788 inconsistencies: vec![],
789 unreachable_servers: vec![],
790 dnssec_validated: false,
791 nameserver_details: None,
792 };
793 assert_eq!(
794 propagation_detail(&result),
795 "the one responding server answered"
796 );
797 let md = MarkdownFormatter::new().format_propagation(&result);
799 assert!(md.contains("the one responding server answered"), "{md}");
800 }
801
802 #[test]
803 fn dnssec_depth_notes_what_each_tier_does_not_verify() {
804 assert_eq!(dnssec_depth(AuthenticationTier::Unsigned).1, None);
805 let digest = dnssec_depth(AuthenticationTier::DigestOnly).1.unwrap();
806 assert!(digest.contains("RRSIG signatures, validity periods"));
807 }
808
809 #[test]
810 fn test_format_as_yaml_empty_collections() {
811 assert_eq!(format_as_yaml(&serde_json::json!([]), 0), "[]");
812 assert_eq!(format_as_yaml(&serde_json::json!({}), 0), "{}");
813 }
814
815 #[test]
818 fn yaml_scalar_quoting_hardened() {
819 use serde_json::json;
820 let y = |s: &str| format_as_yaml(&json!(s), 0);
821
822 assert_eq!(y("No"), "\"No\"");
824 assert_eq!(y("true"), "\"true\"");
825 assert_eq!(y("null"), "\"null\"");
826 assert_eq!(y("~"), "\"~\"");
827
828 assert_eq!(y(""), "\"\"");
830 assert_eq!(y(" leading"), "\" leading\"");
831 assert_eq!(y("trailing "), "\"trailing \"");
832
833 for s in [
835 "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&", "|pipe", ">gt",
836 "%pct", "`tick", "?q", ",comma",
837 ] {
838 assert_eq!(
839 y(s),
840 format!("\"{s}\""),
841 "must quote leading-indicator {s:?}"
842 );
843 }
844
845 assert_eq!(y("key: value"), "\"key: value\"");
847
848 assert_eq!(y("a\tb"), "\"a\\tb\"");
850 assert_eq!(y("a\rb"), "\"a\\rb\"");
851 assert_eq!(y("x\x1b[31m"), "\"x\\x1b[31m\"");
852
853 assert_eq!(y("plain-value"), "plain-value");
855 assert_eq!(y("has internal spaces"), "has internal spaces");
856 assert_eq!(y("ns1.example.com"), "ns1.example.com");
857 }
858
859 #[test]
860 fn yaml_quotes_strings_a_resolver_would_read_as_non_strings() {
861 use serde_json::json;
862 let y = |s: &str| format_as_yaml(&json!(s), 0);
863
864 for s in [
865 "+1.5555550100", "292", "0123", "1:20", "2024-01-15", "2024-01-15T04:00:00Z", ".inf",
872 ".NaN",
873 "=", "<<", "y", "N",
877 ] {
878 assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
879 }
880
881 assert_eq!(y("ns1.example.com"), "ns1.example.com");
883 assert_eq!(y("yes-but-longer"), "yes-but-longer");
884 assert_eq!(y("a=b"), "a=b");
885 assert_eq!(format_as_yaml(&json!(292), 0), "292");
886 assert_eq!(format_as_yaml(&json!(1.5), 0), "1.5");
887 }
888
889 #[test]
890 fn yaml_escapes_unicode_line_breaks() {
891 use serde_json::json;
892 let y = |s: &str| format_as_yaml(&json!(s), 0);
897 assert_eq!(y("a\u{2028}b"), "\"a\\Lb\"");
898 assert_eq!(y("a\u{2029}b"), "\"a\\Pb\"");
899 assert_eq!(y("a\u{0085}b"), "\"a\\Nb\"");
900
901 let doc = format_as_yaml(&json!({ "k\u{2028}ey": "v" }), 0);
902 assert_eq!(doc, "\"k\\Ley\": v");
903 }
904
905 #[test]
906 fn yaml_object_keys_with_significant_chars_are_quoted() {
907 let doc = format_as_yaml(&serde_json::json!({ "a\nb": "v" }), 0);
910 assert!(
911 doc.contains("\"a\\nb\""),
912 "malicious key must be quoted/escaped: {doc:?}"
913 );
914 let doc = format_as_yaml(&serde_json::json!({ "domain": "x" }), 0);
916 assert!(
917 doc.starts_with("domain:"),
918 "plain key must not be quoted: {doc:?}"
919 );
920 }
921}