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_audit(report: crate::audit::AuditReport);
60 format_takeover(report: crate::takeover::TakeoverReport);
61 format_caa(policy: crate::caa::CaaPolicy);
62 format_confusables(report: crate::confusables::ConfusableReport);
63 format_subdomain_classification(result: crate::subdomains::SubdomainClassification);
64 format_subdomain_baseline_diff(report: crate::subdomains::SubdomainBaselineDiff);
65 }
66 };
67}
68
69macro_rules! declare_output_formatter {
70 ($($method:ident($arg:ident: $ty:ty);)+) => {
71 pub trait OutputFormatter {
74 $(fn $method(&self, $arg: &$ty) -> String;)+
75 }
76 };
77}
78
79macro_rules! impl_serializing {
82 ($formatter:ty, $render:ident; $($method:ident($arg:ident: $ty:ty);)+) => {
83 impl OutputFormatter for $formatter {
84 $(fn $method(&self, $arg: &$ty) -> String {
85 self.$render($arg)
86 })+
87 }
88 };
89}
90
91macro_rules! impl_forwarding {
98 ($formatter:ty; $($method:ident($arg:ident: $ty:ty);)+) => {
99 #[deny(unconditional_recursion)]
100 impl OutputFormatter for $formatter {
101 $(fn $method(&self, $arg: &$ty) -> String {
102 self.$method($arg)
103 })+
104 }
105 };
106}
107
108mod contact;
109mod diff_table;
110pub mod dig;
111mod grouping;
112mod human;
113mod json;
114mod markdown;
115
116pub use dig::{dig_short, dig_trace_short};
117pub use human::HumanFormatter;
118pub use json::JsonFormatter;
119pub use markdown::MarkdownFormatter;
120
121use chrono::{DateTime, TimeDelta, Utc};
122use serde::{Deserialize, Serialize};
123
124use crate::dns::{AuthenticationTier, PropagationResult, PropagationVerdict};
125use crate::lookup::LookupResult;
126use crate::subdomains::SubdomainStatus;
127use crate::takeover::TakeoverVerdict;
128
129static_regex! {
130 ANSI_ESCAPE_RE = r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[A-Z@-_]";
134}
135
136fn is_invisible_format(c: char) -> bool {
143 matches!(
144 c,
145 '\u{200B}'..='\u{200F}'
146 | '\u{202A}'..='\u{202E}'
147 | '\u{2066}'..='\u{2069}'
148 | '\u{061C}'
149 | '\u{FEFF}'
150 )
151}
152
153pub fn sanitize_line(s: &str) -> String {
162 ANSI_ESCAPE_RE
163 .replace_all(s, "")
164 .chars()
165 .filter_map(|c| match c {
166 '\n' | '\t' | '\u{2028}' | '\u{2029}' => Some(' '),
167 c if c.is_control() || is_invisible_format(c) => None,
168 c => Some(c),
169 })
170 .collect()
171}
172
173fn days_until(when: DateTime<Utc>) -> i64 {
177 crate::dates::days_until(when, Utc::now())
178}
179
180fn day_count(days: i64) -> String {
182 if days == 1 {
183 "1 day".to_string()
184 } else {
185 format!("{days} days")
186 }
187}
188
189fn expiry_phrase(days_until: i64) -> String {
192 if days_until < 0 {
193 format!("expired {} ago", day_count(-days_until))
194 } else {
195 format!("expires in {}", day_count(days_until))
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum Emphasis {
203 Good,
205 Neutral,
207 Caution,
209 Bad,
211}
212
213fn expiry_emphasis(days_until: i64) -> Emphasis {
217 match days_until {
218 d if d < 30 => Emphasis::Bad,
219 d if d < 90 => Emphasis::Caution,
220 _ => Emphasis::Good,
221 }
222}
223
224pub fn availability_label(verdict: &str) -> (&'static str, Emphasis) {
228 match verdict {
229 "available" => ("AVAILABLE", Emphasis::Good),
230 "likely_available" => ("MAY BE AVAILABLE", Emphasis::Caution),
231 "registered" => ("REGISTERED", Emphasis::Neutral),
232 "likely_registered" => ("LIKELY REGISTERED", Emphasis::Caution),
233 _ => ("UNKNOWN", Emphasis::Bad),
234 }
235}
236
237fn lookup_source(result: &LookupResult) -> &'static str {
240 match result {
241 LookupResult::Rdap { .. } => "RDAP",
242 LookupResult::Whois {
243 rdap_error: None, ..
244 } => "WHOIS",
245 LookupResult::Whois { .. }
246 | LookupResult::Available {
247 whois_data: Some(_),
248 ..
249 } => "WHOIS (RDAP unavailable)",
250 LookupResult::Available { .. } => "availability check (RDAP and WHOIS failed)",
251 }
252}
253
254pub(crate) fn dns_delegation_verdict(ns: &crate::DnsNameservers) -> String {
258 if ns.in_sync {
259 return "matches WHOIS".to_string();
260 }
261 if ns.whois_lists_none() {
262 return "WHOIS lists no nameservers".to_string();
263 }
264 let mut parts = Vec::new();
265 if !ns.missing_from_whois.is_empty() {
266 parts.push(format!(
267 "delegated but not in WHOIS: {}",
268 ns.missing_from_whois.join(", ")
269 ));
270 }
271 if !ns.missing_from_dns.is_empty() {
272 parts.push(format!(
273 "in WHOIS but not delegated: {}",
274 ns.missing_from_dns.join(", ")
275 ));
276 }
277 format!("differs from WHOIS ({})", parts.join("; "))
278}
279
280pub(crate) fn dns_delegation_differs(ns: &crate::DnsNameservers) -> bool {
283 !ns.in_sync && !ns.whois_lists_none()
284}
285
286fn format_duration(duration: TimeDelta) -> String {
288 let total_secs = duration.num_seconds();
289 if total_secs < 60 {
290 format!("{total_secs}s")
291 } else if total_secs < 3600 {
292 format!("{}m {}s", total_secs / 60, total_secs % 60)
293 } else {
294 format!("{}h {}m", total_secs / 3600, (total_secs % 3600) / 60)
295 }
296}
297
298const DNSSEC_NOTE: &str = "Note: DNS responses are not DNSSEC-validated";
302
303fn dnssec_depth(tier: AuthenticationTier) -> (&'static str, Option<&'static str>) {
307 match tier {
308 AuthenticationTier::Unsigned => ("unsigned (no DNSSEC records)", None),
309 AuthenticationTier::DigestOnly => (
310 "digest-only (DS↔DNSKEY consistency)",
311 Some(
312 "Note: reflects DS/DNSKEY digest consistency only — RRSIG signatures, \
313 validity periods, and the chain to the root are NOT cryptographically \
314 verified.",
315 ),
316 ),
317 }
318}
319
320fn propagation_detail(result: &PropagationResult) -> String {
323 let responding = result.servers_responding;
324 match result.verdict() {
325 PropagationVerdict::NoAnswer => {
326 format!("none of the {} servers answered", result.servers_checked)
327 }
328 PropagationVerdict::Full if responding == 1 => {
329 "the one responding server answered".to_string()
330 }
331 PropagationVerdict::Full => format!("all {responding} responding servers agree"),
332 _ => format!(
333 "{} of {responding} responding servers agree",
334 result.servers_agreeing()
335 ),
336 }
337}
338
339pub fn takeover_label(verdict: TakeoverVerdict) -> &'static str {
342 match verdict {
343 TakeoverVerdict::Vulnerable => "VULNERABLE",
344 TakeoverVerdict::Potential => "potential",
345 TakeoverVerdict::Inconclusive => "inconclusive",
346 TakeoverVerdict::Safe => "safe",
347 }
348}
349
350fn subdomain_status_label(status: SubdomainStatus) -> &'static str {
352 match status {
353 SubdomainStatus::Live => "live",
354 SubdomainStatus::Dead => "dead",
355 SubdomainStatus::Wildcard => "wildcard",
356 SubdomainStatus::Unknown => "unknown",
357 }
358}
359
360pub fn propagation_difference(inc: &crate::dns::Inconsistency, empty_label: &str) -> String {
366 const INLINE_VALUES: usize = 6;
367 if inc.values.is_empty() {
368 return format!("no records ({empty_label})");
369 }
370 if inc.values.len() + inc.consensus.len() <= INLINE_VALUES {
371 return inc.values.join(", ");
372 }
373 let mut parts = Vec::new();
374 let missing = inc.missing_count();
375 if missing > 0 {
376 parts.push(format!("missing {missing} of {}", inc.consensus.len()));
377 }
378 let extra = inc.extra_values();
379 if !extra.is_empty() {
380 parts.push(format!("extra: {}", extra.join(", ")));
381 }
382 parts.join("; ")
383}
384
385const GEO_NOTE: &str = "Note: answers vary by resolver location, typical of GeoDNS/CDN \
388 names rather than a propagation delay";
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
391#[serde(rename_all = "lowercase")]
392pub enum OutputFormat {
393 #[default]
394 Human,
395 Json,
396 Yaml,
397 Markdown,
398}
399
400impl std::str::FromStr for OutputFormat {
401 type Err = String;
402
403 fn from_str(s: &str) -> Result<Self, Self::Err> {
404 match s.to_lowercase().as_str() {
405 "human" | "text" | "pretty" => Ok(OutputFormat::Human),
406 "json" => Ok(OutputFormat::Json),
407 "yaml" | "yml" => Ok(OutputFormat::Yaml),
408 "markdown" | "md" => Ok(OutputFormat::Markdown),
409 _ => Err(format!(
410 "Unknown output format: {}. Use: human, json, yaml, markdown",
411 s
412 )),
413 }
414 }
415}
416
417with_report_methods!(declare_output_formatter!());
418
419#[derive(Default)]
421pub struct YamlFormatter;
422
423impl YamlFormatter {
424 pub fn new() -> Self {
425 Self
426 }
427
428 pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
430 match serde_json::to_value(value) {
432 Ok(v) => format_as_yaml(&v, 0),
433 Err(e) => format!("error: {}", yaml_scalar(&e.to_string())),
434 }
435 }
436}
437
438with_report_methods!(impl_serializing!(YamlFormatter, to_yaml_value;));
439
440fn yaml_needs_quoting(s: &str) -> bool {
459 if s.is_empty() || s != s.trim() {
460 return true;
461 }
462 if s.chars()
463 .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
464 {
465 return true;
466 }
467 if matches!(
468 s.to_ascii_lowercase().as_str(),
469 "null" | "~" | "true" | "false" | "yes" | "no" | "y" | "n" | "on" | "off" | "=" | "<<"
470 ) {
471 return true;
472 }
473 if let Some(first) = s.chars().next() {
474 if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
476 return true;
477 }
478 if first.is_ascii_digit() || first == '+' || first == '.' {
480 return true;
481 }
482 }
483 s.contains(": ") || s.ends_with(':') || s.contains(" #")
484}
485
486fn yaml_quote(s: &str) -> String {
490 let mut out = String::with_capacity(s.len() + 2);
491 out.push('"');
492 for c in s.chars() {
493 match c {
494 '"' => out.push_str("\\\""),
495 '\\' => out.push_str("\\\\"),
496 '\n' => out.push_str("\\n"),
497 '\t' => out.push_str("\\t"),
498 '\r' => out.push_str("\\r"),
499 '\0' => out.push_str("\\0"),
500 '\u{0085}' => out.push_str("\\N"),
503 '\u{2028}' => out.push_str("\\L"),
504 '\u{2029}' => out.push_str("\\P"),
505 c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
507 c => out.push(c),
508 }
509 }
510 out.push('"');
511 out
512}
513
514fn yaml_scalar(s: &str) -> String {
516 if yaml_needs_quoting(s) {
517 yaml_quote(s)
518 } else {
519 s.to_string()
520 }
521}
522
523fn is_yaml_block(value: &serde_json::Value) -> bool {
526 match value {
527 serde_json::Value::Array(arr) => !arr.is_empty(),
528 serde_json::Value::Object(map) => !map.is_empty(),
529 _ => false,
530 }
531}
532
533fn format_as_yaml(value: &serde_json::Value, indent: usize) -> String {
540 let prefix = " ".repeat(indent);
541 match value {
542 serde_json::Value::Null => "null".to_string(),
543 serde_json::Value::Bool(b) => b.to_string(),
544 serde_json::Value::Number(n) => n.to_string(),
545 serde_json::Value::String(s) => yaml_scalar(s),
546 serde_json::Value::Array(arr) if arr.is_empty() => "[]".to_string(),
547 serde_json::Value::Object(map) if map.is_empty() => "{}".to_string(),
548 serde_json::Value::Array(arr) => {
549 let item_prefix = " ".repeat(indent + 1);
550 arr.iter()
551 .map(|item| {
552 let rendered = format_as_yaml(item, indent + 1);
553 let first_line = rendered.strip_prefix(&item_prefix).unwrap_or(&rendered);
554 format!("{prefix}- {first_line}")
555 })
556 .collect::<Vec<_>>()
557 .join("\n")
558 }
559 serde_json::Value::Object(map) => map
560 .iter()
561 .map(|(key, val)| {
562 let key = yaml_scalar(key);
565 if is_yaml_block(val) {
566 format!("{prefix}{key}:\n{}", format_as_yaml(val, indent + 1))
567 } else {
568 format!("{prefix}{key}: {}", format_as_yaml(val, indent))
569 }
570 })
571 .collect::<Vec<_>>()
572 .join("\n"),
573 }
574}
575
576pub fn get_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
577 match format {
578 OutputFormat::Human => Box::new(HumanFormatter::new()),
579 OutputFormat::Json => Box::new(JsonFormatter::new()),
580 OutputFormat::Yaml => Box::new(YamlFormatter::new()),
581 OutputFormat::Markdown => Box::new(MarkdownFormatter::new()),
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn dns_delegation_with_no_whois_nameservers_is_not_a_mismatch() {
591 let ns = |w: &[&str], d: &[&str]| {
592 crate::DnsNameservers::compare(
593 &w.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
594 d.iter().map(|s| s.to_string()).collect(),
595 )
596 };
597 let none = ns(&[], &["a.ns.test", "b.ns.test"]);
599 assert!(none.whois_lists_none());
600 assert!(!dns_delegation_differs(&none));
601 assert_eq!(dns_delegation_verdict(&none), "WHOIS lists no nameservers");
602 let partial = ns(&["a.ns.test"], &["a.ns.test", "b.ns.test"]);
604 assert!(!partial.whois_lists_none());
605 assert!(dns_delegation_differs(&partial));
606 assert!(dns_delegation_verdict(&partial).starts_with("differs from WHOIS"));
607 let wrong = ns(&["x.ns.test"], &["a.ns.test"]);
609 assert!(!wrong.whois_lists_none());
610 assert!(dns_delegation_differs(&wrong));
611 let empty = ns(&[], &[]);
613 assert!(!empty.whois_lists_none());
614 assert!(!dns_delegation_differs(&empty));
615 }
616
617 #[test]
618 fn test_output_format_from_str() {
619 assert_eq!(
620 "human".parse::<OutputFormat>().unwrap(),
621 OutputFormat::Human
622 );
623 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
624 assert_eq!("yaml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
625 assert_eq!("yml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
626 assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Human);
627 assert_eq!(
628 "pretty".parse::<OutputFormat>().unwrap(),
629 OutputFormat::Human
630 );
631 assert!("invalid".parse::<OutputFormat>().is_err());
632 }
633
634 #[test]
635 fn test_output_format_default() {
636 assert_eq!(OutputFormat::default(), OutputFormat::Human);
637 }
638
639 #[test]
640 fn test_get_formatter_returns_correct_type() {
641 let _ = get_formatter(OutputFormat::Human);
643 let _ = get_formatter(OutputFormat::Json);
644 let _ = get_formatter(OutputFormat::Yaml);
645 }
646
647 #[test]
648 fn test_yaml_formatter_basic() {
649 let formatter = YamlFormatter::new();
650 let status = crate::status::StatusResponse::new("example.com".to_string());
651 let output = formatter.format_status(&status);
652 assert!(output.contains("example.com"));
653 assert!(output.contains("domain"));
654 }
655
656 #[test]
657 fn test_format_as_yaml_primitives() {
658 assert_eq!(format_as_yaml(&serde_json::json!(null), 0), "null");
659 assert_eq!(format_as_yaml(&serde_json::json!(true), 0), "true");
660 assert_eq!(format_as_yaml(&serde_json::json!(42), 0), "42");
661 assert_eq!(format_as_yaml(&serde_json::json!("hello"), 0), "hello");
662 }
663
664 #[test]
665 fn test_format_as_yaml_array() {
666 let output = format_as_yaml(&serde_json::json!([1, 2, 3]), 0);
667 assert!(output.contains("- 1"));
668 assert!(output.contains("- 2"));
669 assert!(output.contains("- 3"));
670 }
671
672 #[test]
673 fn yaml_is_clean_block_style_without_blank_or_trailing_space_lines() {
674 use serde_json::json;
675 let doc = format_as_yaml(
678 &json!([
679 {"name": "a", "ttl": 300},
680 {"name": "b", "tags": ["x", "z"], "empty": [], "meta": {"k": "v"}},
681 [1, 2]
682 ]),
683 0,
684 );
685 let expected = "\
686- name: a
687 ttl: 300
688- empty: []
689 meta:
690 k: v
691 name: b
692 tags:
693 - x
694 - z
695- - 1
696 - 2";
697 assert_eq!(doc, expected);
698 for line in doc.lines() {
699 assert!(!line.ends_with(' '), "trailing space: {line:?}");
700 }
701
702 let doc = format_as_yaml(&json!({"domain": "x", "records": [{"a": 1}]}), 0);
703 assert_eq!(doc, "domain: x\nrecords:\n - a: 1");
704 }
705
706 #[test]
707 fn sanitize_line_strips_controls_and_folds_line_breaks() {
708 assert_eq!(sanitize_line("a\rb\x08c\x07d\u{009b}e"), "abcde");
712 assert_eq!(
713 sanitize_line("line1\nline2\tcol\u{2028}x"),
714 "line1 line2 col x"
715 );
716 assert_eq!(
718 sanitize_line("\x1b]0;malicious title\x1b\\visible"),
719 "visible"
720 );
721 assert_eq!(sanitize_line("\x1b[31mred\x1b[0m"), "red");
722 }
723
724 #[test]
725 fn sanitize_line_drops_bidi_and_zero_width_format_chars() {
726 let trojan = "a\u{202E}b\u{2066}c\u{200B}d\u{FEFF}e\u{061C}f\u{200E}g\u{2069}";
729 assert_eq!(sanitize_line(trojan), "abcdefg");
730 assert_eq!(sanitize_line("café — ü"), "café — ü");
732 }
733
734 #[test]
735 fn expiry_phrase_is_one_wording_for_past_and_future() {
736 assert_eq!(expiry_phrase(-5), "expired 5 days ago");
737 assert_eq!(expiry_phrase(-1), "expired 1 day ago");
738 assert_eq!(expiry_phrase(0), "expires in 0 days");
739 assert_eq!(expiry_phrase(1), "expires in 1 day");
740 assert_eq!(expiry_phrase(29), "expires in 29 days");
741 assert_eq!(expiry_emphasis(-1), Emphasis::Bad);
742 assert_eq!(expiry_emphasis(29), Emphasis::Bad);
743 assert_eq!(expiry_emphasis(30), Emphasis::Caution);
744 assert_eq!(expiry_emphasis(90), Emphasis::Good);
745 }
746
747 #[test]
748 fn availability_label_covers_every_verdict_and_tolerates_others() {
749 assert_eq!(availability_label("available").0, "AVAILABLE");
750 assert_eq!(availability_label("likely_available").0, "MAY BE AVAILABLE");
751 assert_eq!(availability_label("registered").0, "REGISTERED");
752 assert_eq!(
753 availability_label("likely_registered").0,
754 "LIKELY REGISTERED"
755 );
756 assert_eq!(availability_label("unknown"), ("UNKNOWN", Emphasis::Bad));
757 assert_eq!(availability_label("whatever"), ("UNKNOWN", Emphasis::Bad));
758 }
759
760 #[test]
761 fn propagation_detail_names_a_lone_responder() {
762 use crate::dns::{
763 ConsensusValue, DnsRecord, DnsServer, DnsStatus, PropagationServerResult, RecordData,
764 RecordType,
765 };
766 let answered = PropagationServerResult {
767 server: DnsServer::new("Google", "8.8.8.8", "NA", "Google"),
768 records: vec![DnsRecord {
769 name: "example.com".into(),
770 record_type: RecordType::A,
771 ttl: 300,
772 data: RecordData::A {
773 address: "1.2.3.4".into(),
774 },
775 }],
776 response_time_ms: 12,
777 success: true,
778 error: None,
779 status: Some(DnsStatus::NoError),
780 };
781 let result = PropagationResult {
782 domain: "example.com".into(),
783 record_type: RecordType::A,
784 servers_checked: 2,
785 servers_responding: 1,
786 propagation_percentage: 100.0,
787 results: vec![answered],
788 consensus_values: vec![ConsensusValue::new(RecordType::A, "1.2.3.4")],
789 inconsistencies: vec![],
790 unreachable_servers: vec![],
791 dnssec_validated: false,
792 nameserver_details: None,
793 };
794 assert_eq!(
795 propagation_detail(&result),
796 "the one responding server answered"
797 );
798 let md = MarkdownFormatter::new().format_propagation(&result);
800 assert!(md.contains("the one responding server answered"), "{md}");
801 }
802
803 #[test]
804 fn dnssec_depth_notes_what_each_tier_does_not_verify() {
805 assert_eq!(dnssec_depth(AuthenticationTier::Unsigned).1, None);
806 let digest = dnssec_depth(AuthenticationTier::DigestOnly).1.unwrap();
807 assert!(digest.contains("RRSIG signatures, validity periods"));
808 }
809
810 #[test]
811 fn test_format_as_yaml_empty_collections() {
812 assert_eq!(format_as_yaml(&serde_json::json!([]), 0), "[]");
813 assert_eq!(format_as_yaml(&serde_json::json!({}), 0), "{}");
814 }
815
816 #[test]
819 fn yaml_scalar_quoting_hardened() {
820 use serde_json::json;
821 let y = |s: &str| format_as_yaml(&json!(s), 0);
822
823 assert_eq!(y("No"), "\"No\"");
825 assert_eq!(y("true"), "\"true\"");
826 assert_eq!(y("null"), "\"null\"");
827 assert_eq!(y("~"), "\"~\"");
828
829 assert_eq!(y(""), "\"\"");
831 assert_eq!(y(" leading"), "\" leading\"");
832 assert_eq!(y("trailing "), "\"trailing \"");
833
834 for s in [
836 "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&", "|pipe", ">gt",
837 "%pct", "`tick", "?q", ",comma",
838 ] {
839 assert_eq!(
840 y(s),
841 format!("\"{s}\""),
842 "must quote leading-indicator {s:?}"
843 );
844 }
845
846 assert_eq!(y("key: value"), "\"key: value\"");
848
849 assert_eq!(y("a\tb"), "\"a\\tb\"");
851 assert_eq!(y("a\rb"), "\"a\\rb\"");
852 assert_eq!(y("x\x1b[31m"), "\"x\\x1b[31m\"");
853
854 assert_eq!(y("plain-value"), "plain-value");
856 assert_eq!(y("has internal spaces"), "has internal spaces");
857 assert_eq!(y("ns1.example.com"), "ns1.example.com");
858 }
859
860 #[test]
861 fn yaml_quotes_strings_a_resolver_would_read_as_non_strings() {
862 use serde_json::json;
863 let y = |s: &str| format_as_yaml(&json!(s), 0);
864
865 for s in [
866 "+1.5555550100", "292", "0123", "1:20", "2024-01-15", "2024-01-15T04:00:00Z", ".inf",
873 ".NaN",
874 "=", "<<", "y", "N",
878 ] {
879 assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
880 }
881
882 assert_eq!(y("ns1.example.com"), "ns1.example.com");
884 assert_eq!(y("yes-but-longer"), "yes-but-longer");
885 assert_eq!(y("a=b"), "a=b");
886 assert_eq!(format_as_yaml(&json!(292), 0), "292");
887 assert_eq!(format_as_yaml(&json!(1.5), 0), "1.5");
888 }
889
890 #[test]
891 fn yaml_escapes_unicode_line_breaks() {
892 use serde_json::json;
893 let y = |s: &str| format_as_yaml(&json!(s), 0);
898 assert_eq!(y("a\u{2028}b"), "\"a\\Lb\"");
899 assert_eq!(y("a\u{2029}b"), "\"a\\Pb\"");
900 assert_eq!(y("a\u{0085}b"), "\"a\\Nb\"");
901
902 let doc = format_as_yaml(&json!({ "k\u{2028}ey": "v" }), 0);
903 assert_eq!(doc, "\"k\\Ley\": v");
904 }
905
906 #[test]
907 fn yaml_object_keys_with_significant_chars_are_quoted() {
908 let doc = format_as_yaml(&serde_json::json!({ "a\nb": "v" }), 0);
911 assert!(
912 doc.contains("\"a\\nb\""),
913 "malicious key must be quoted/escaped: {doc:?}"
914 );
915 let doc = format_as_yaml(&serde_json::json!({ "domain": "x" }), 0);
917 assert!(
918 doc.starts_with("domain:"),
919 "plain key must not be quoted: {doc:?}"
920 );
921 }
922}