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