1use crate::output::OutputFormat;
2use crate::redaction::{OutputOptions, PlainStyle, REDACTED_MARKER};
3use serde_json::Value;
4
5pub fn render(value: &Value, format: OutputFormat, options: &OutputOptions) -> String {
11 match format {
12 OutputFormat::Json => serialize_json_output(&options.redaction.value(value)),
13 OutputFormat::Yaml => render_yaml(value, options),
14 OutputFormat::Plain => render_plain(value, options),
15 }
16}
17
18pub(crate) fn serialize_json_output(value: &Value) -> String {
19 match serde_json::to_string(value) {
20 Ok(s) => s,
21 Err(err) => serde_json::json!({
22 "error": "output_json_failed",
23 "detail": err.to_string(),
24 })
25 .to_string(),
26 }
27}
28
29pub(crate) fn render_yaml(value: &Value, output_options: &OutputOptions) -> String {
34 let mut lines = vec!["---".to_string()];
35 let v = output_options.redaction.value(value);
36 render_yaml_raw(&v, 0, &mut lines);
37 lines.join("\n")
38}
39
40pub(crate) fn render_plain(value: &Value, output_options: &OutputOptions) -> String {
42 let mut pairs: Vec<(String, String)> = Vec::new();
43 let v = output_options.redaction.value(value);
44 if !v.is_object() {
45 return plain_scalar(&v);
46 }
47 match output_options.style {
48 PlainStyle::Readable => collect_plain_pairs(&v, "", &mut pairs),
49 PlainStyle::Raw => collect_plain_pairs_raw(&v, "", &mut pairs),
50 }
51 if pairs.is_empty() {
52 return "{}".to_string();
53 }
54 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
55 pairs
56 .into_iter()
57 .map(|(k, v)| format!("{}={}", quote_logfmt_key(&k), quote_logfmt_value(&v)))
58 .collect::<Vec<_>>()
59 .join(" ")
60}
61
62pub(crate) fn has_suffix_ci(key: &str, suffix_lower: &str) -> bool {
71 strip_suffix_ci(key, suffix_lower).is_some()
72}
73
74pub(crate) fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
76 if let Some(s) = key.strip_suffix(suffix_lower) {
77 return Some(s.to_string());
78 }
79 let suffix_upper: String = suffix_lower
80 .chars()
81 .map(|c| c.to_ascii_uppercase())
82 .collect();
83 if let Some(s) = key.strip_suffix(&suffix_upper) {
84 return Some(s.to_string());
85 }
86 None
87}
88
89fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
91 let code = extract_currency_code(key)?;
92 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
94 if stripped.is_empty() {
95 return None;
96 }
97 Some((stripped.to_string(), code.to_string()))
98}
99
100fn try_strip_generic_micro(key: &str) -> Option<(String, String)> {
102 let code = extract_currency_code_micro(key)?;
103 let suffix_len = code.len() + "_micro".len() + 1; let stripped = &key[..key.len() - suffix_len];
105 if stripped.is_empty() {
106 return None;
107 }
108 Some((stripped.to_string(), code.to_string()))
109}
110
111fn as_int(value: &Value) -> Option<i64> {
117 if let Some(i) = value.as_i64() {
118 return Some(i);
119 }
120 if value.is_u64() {
121 return value.as_u64()?.try_into().ok();
122 }
123 if let Value::Number(number) = value
130 && number_is_integer_literal(number)
131 {
132 return None;
133 }
134 let f = value.as_f64()?;
135 let upper_exclusive = -(i64::MIN as f64);
136 if f.is_finite() && f.fract() == 0.0 && f >= i64::MIN as f64 && f < upper_exclusive {
137 return Some(f as i64);
138 }
139 None
140}
141
142pub(crate) fn number_is_integer_literal(number: &serde_json::Number) -> bool {
147 !number.to_string().contains(['.', 'e', 'E'])
148}
149
150fn as_uint(value: &Value) -> Option<u64> {
152 if let Some(u) = value.as_u64() {
153 return Some(u);
154 }
155 let f = value.as_f64()?;
156 if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
157 return Some(f as u64);
158 }
159 None
160}
161
162fn integer_text(value: &Value) -> Option<String> {
163 match value {
164 Value::Number(_) => as_int(value).map(|n| n.to_string()),
165 Value::String(s) if is_decimal_integer_string(s) => Some(s.clone()),
166 _ => None,
167 }
168}
169
170fn is_decimal_integer_string(s: &str) -> bool {
171 let digits = s.strip_prefix('-').unwrap_or(s);
172 !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
173}
174
175fn epoch_ns_to_ms(value: &Value) -> Option<i64> {
176 let ns = match value {
177 Value::Number(_) => i128::from(as_int(value)?),
178 Value::String(s) if is_decimal_integer_string(s) => s.parse::<i128>().ok()?,
179 _ => return None,
180 };
181 ns.div_euclid(1_000_000).try_into().ok()
182}
183
184fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
188 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
190 return as_int(value)
191 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
192 }
193 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
194 return as_int(value)
195 .and_then(|s| s.checked_mul(1000))
196 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
197 }
198 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
199 return epoch_ns_to_ms(value)
200 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
201 }
202
203 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
205 return as_int(value).map(|n| {
206 let (sign, magnitude) = signed_magnitude(n);
207 (
208 stripped,
209 format!("{sign}${}.{:02}", magnitude / 100, magnitude % 100),
210 )
211 });
212 }
213 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
214 return as_int(value).map(|n| {
215 let (sign, magnitude) = signed_magnitude(n);
216 (
217 stripped,
218 format!("{sign}€{}.{:02}", magnitude / 100, magnitude % 100),
219 )
220 });
221 }
222 if let Some((stripped, code)) = try_strip_generic_cents(key) {
223 return as_int(value).map(|n| {
224 let (sign, magnitude) = signed_magnitude(n);
225 (
226 stripped,
227 format!(
228 "{sign}{}.{:02} {}",
229 magnitude / 100,
230 magnitude % 100,
231 code.to_uppercase()
232 ),
233 )
234 });
235 }
236 if let Some((stripped, code)) = try_strip_generic_micro(key) {
237 return as_int(value).map(|n| {
238 let (sign, magnitude) = signed_magnitude(n);
239 (
240 stripped,
241 format!(
242 "{sign}{}.{:06} {}",
243 magnitude / 1_000_000,
244 magnitude % 1_000_000,
245 code.to_uppercase()
246 ),
247 )
248 });
249 }
250
251 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
253 return value.as_str().map(|s| (stripped, s.to_string()));
254 }
255 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
256 return value
257 .is_number()
258 .then(|| (stripped, format!("{} minutes", number_str(value))));
259 }
260 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
261 return value
262 .is_number()
263 .then(|| (stripped, format!("{} hours", number_str(value))));
264 }
265 if let Some(stripped) = strip_suffix_ci(key, "_days") {
266 return value
267 .is_number()
268 .then(|| (stripped, format!("{} days", number_str(value))));
269 }
270
271 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
273 return integer_text(value).map(|n| (stripped, format!("{n}msats")));
274 }
275 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
276 return integer_text(value).map(|n| (stripped, format!("{n}sats")));
277 }
278 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
279 return as_uint(value).map(|n| (stripped, format_bytes_human(n)));
280 }
281 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
282 return value
283 .is_number()
284 .then(|| (stripped, format!("{}%", number_str(value))));
285 }
286 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
288 return as_int(value).map(|n| {
289 let (sign, magnitude) = signed_magnitude(n);
290 (
291 stripped,
292 format!("{sign}¥{}", format_with_commas(magnitude)),
293 )
294 });
295 }
296 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
297 return value
298 .is_number()
299 .then(|| (stripped, format!("{}ns", number_str(value))));
300 }
301 if let Some(stripped) = strip_suffix_ci(key, "_us") {
302 return value
303 .is_number()
304 .then(|| (stripped, format!("{}μs", number_str(value))));
305 }
306 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
307 return format_ms_value(value).map(|v| (stripped, v));
308 }
309 if let Some(stripped) = strip_suffix_ci(key, "_s") {
310 return value
311 .is_number()
312 .then(|| (stripped, format!("{}s", number_str(value))));
313 }
314
315 None
316}
317
318fn is_redacted(value: &Value) -> bool {
321 value.as_str() == Some(REDACTED_MARKER)
322}
323
324fn process_object_fields<'a>(
326 map: &'a serde_json::Map<String, Value>,
327) -> Vec<(String, &'a Value, Option<String>)> {
328 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
329 for (key, value) in map {
330 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
331 let display_key = if is_redacted(value) {
338 stripped
339 } else {
340 key.clone()
341 };
342 entries.push((display_key, key.as_str(), value, None));
343 continue;
344 }
345 match try_process_field(key, value) {
346 Some((stripped, formatted)) => {
347 entries.push((stripped, key.as_str(), value, Some(formatted)));
348 }
349 None => {
350 entries.push((key.clone(), key.as_str(), value, None));
351 }
352 }
353 }
354
355 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
357 for (stripped, _, _, _) in &entries {
358 *counts.entry(stripped.clone()).or_insert(0) += 1;
359 }
360
361 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
363 .into_iter()
364 .map(|(stripped, original, value, formatted)| {
365 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
366 (original.to_string(), value, None)
367 } else {
368 (stripped, value, formatted)
369 }
370 })
371 .collect();
372
373 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
374 result
375}
376
377fn number_str(value: &Value) -> String {
382 match value {
383 Value::Number(n) => format_number(n),
384 _ => String::new(),
385 }
386}
387
388fn format_number(n: &serde_json::Number) -> String {
394 if n.is_f64()
395 && let Some(f) = n.as_f64()
396 && f.is_finite()
397 && f.fract() == 0.0
398 && f.abs() < 1e21
399 {
400 return format!("{f:.0}");
401 }
402 normalize_exponent(&n.to_string())
403}
404
405fn normalize_exponent(s: &str) -> String {
406 let Some(e) = s.find(['e', 'E']) else {
407 return s.to_string();
408 };
409 let mantissa = &s[..e];
410 let mut exp = &s[e + 1..];
411 let mut sign = "";
412 if exp.starts_with(['+', '-']) {
413 sign = &exp[..1];
414 exp = &exp[1..];
415 }
416 let exp = exp.trim_start_matches('0');
417 let exp = if exp.is_empty() { "0" } else { exp };
418 format!("{mantissa}e{sign}{exp}")
419}
420
421fn format_ms_as_seconds(ms: f64) -> String {
423 let formatted = format!("{:.3}", ms / 1000.0);
424 let trimmed = formatted.trim_end_matches('0');
425 if trimmed.ends_with('.') {
426 format!("{}0s", trimmed)
427 } else {
428 format!("{}s", trimmed)
429 }
430}
431
432fn signed_magnitude(value: i64) -> (&'static str, u64) {
439 if value < 0 {
440 ("-", value.unsigned_abs())
441 } else {
442 ("", value as u64)
443 }
444}
445
446fn format_ms_value(value: &Value) -> Option<String> {
448 let n = value.as_f64()?;
449 if n.abs() >= 1000.0 {
450 Some(format_ms_as_seconds(n))
451 } else if let Some(i) = value.as_i64() {
452 Some(format!("{}ms", i))
453 } else {
454 Some(format!("{}ms", number_str(value)))
455 }
456}
457
458const MIN_RFC3339_MS: i64 = -62135596800000;
460const MAX_RFC3339_MS: i64 = 253402300799999;
461
462fn format_rfc3339_ms(ms: i64) -> Option<String> {
463 use chrono::{DateTime, Utc};
464 if !(MIN_RFC3339_MS..=MAX_RFC3339_MS).contains(&ms) {
465 return None;
466 }
467 let secs = ms.div_euclid(1000);
468 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
469 DateTime::from_timestamp(secs, nanos).map(|dt| {
470 dt.with_timezone(&Utc)
471 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
472 })
473}
474
475pub(crate) fn format_bytes_human(bytes: u64) -> String {
477 const KIB: f64 = 1024.0;
478 const MIB: f64 = KIB * 1024.0;
479 const GIB: f64 = MIB * 1024.0;
480 const TIB: f64 = GIB * 1024.0;
481
482 let b = bytes as f64;
483 if b >= TIB {
484 format!("{:.1}TiB", b / TIB)
485 } else if b >= GIB {
486 format!("{:.1}GiB", b / GIB)
487 } else if b >= MIB {
488 format!("{:.1}MiB", b / MIB)
489 } else if b >= KIB {
490 format!("{:.1}KiB", b / KIB)
491 } else {
492 format!("{bytes}B")
493 }
494}
495
496pub(crate) fn format_with_commas(n: u64) -> String {
498 let s = n.to_string();
499 let mut result = String::with_capacity(s.len() + s.len() / 3);
500 for (i, c) in s.chars().enumerate() {
501 if i > 0 && (s.len() - i).is_multiple_of(3) {
502 result.push(',');
503 }
504 result.push(c);
505 }
506 result
507}
508
509pub(crate) fn extract_currency_code(key: &str) -> Option<&str> {
511 let without_cents = key
512 .strip_suffix("_cents")
513 .or_else(|| key.strip_suffix("_CENTS"))?;
514 extract_currency_code_from_stem(without_cents)
515}
516
517pub(crate) fn extract_currency_code_micro(key: &str) -> Option<&str> {
519 let without_micro = key
520 .strip_suffix("_micro")
521 .or_else(|| key.strip_suffix("_MICRO"))?;
522 extract_currency_code_from_stem(without_micro)
523}
524
525fn extract_currency_code_from_stem(stem: &str) -> Option<&str> {
526 let last_underscore = stem.rfind('_')?;
527 let code = &stem[last_underscore + 1..];
528 if code.is_empty()
529 || !(3..=4).contains(&code.len())
530 || !code.bytes().all(|b| b.is_ascii_alphabetic())
531 {
532 return None;
533 }
534 Some(code)
535}
536
537fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
542 let prefix = " ".repeat(indent);
543 match value {
544 Value::Object(map) => {
545 for key in sorted_value_keys(map) {
546 render_yaml_field_raw(&prefix, &key, &map[&key], indent, lines);
547 }
548 }
549 Value::Array(arr) => {
550 render_yaml_array_raw(arr, indent, lines);
551 }
552 _ => {
553 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
554 }
555 }
556}
557
558fn render_yaml_field_raw(
559 prefix: &str,
560 key: &str,
561 value: &Value,
562 indent: usize,
563 lines: &mut Vec<String>,
564) {
565 match value {
566 Value::Object(inner) if !inner.is_empty() => {
567 lines.push(format!("{}{}:", prefix, yaml_key(key)));
568 render_yaml_raw(value, indent + 1, lines);
569 }
570 Value::Object(_) => {
571 lines.push(format!("{}{}: {{}}", prefix, yaml_key(key)));
572 }
573 Value::Array(arr) => {
574 if arr.is_empty() {
575 lines.push(format!("{}{}: []", prefix, yaml_key(key)));
576 } else {
577 lines.push(format!("{}{}:", prefix, yaml_key(key)));
578 render_yaml_array_raw(arr, indent + 1, lines);
579 }
580 }
581 _ => {
582 lines.push(format!(
583 "{}{}: {}",
584 prefix,
585 yaml_key(key),
586 yaml_scalar(value)
587 ));
588 }
589 }
590}
591
592fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
593 let prefix = " ".repeat(indent);
594 for item in arr {
595 match item {
596 Value::Object(inner) if !inner.is_empty() => {
597 lines.push(format!("{}-", prefix));
598 render_yaml_raw(item, indent + 1, lines);
599 }
600 Value::Array(nested) if !nested.is_empty() => {
601 lines.push(format!("{}-", prefix));
602 render_yaml_array_raw(nested, indent + 1, lines);
603 }
604 Value::Object(_) => {
605 lines.push(format!("{}- {{}}", prefix));
606 }
607 Value::Array(_) => {
608 lines.push(format!("{}- []", prefix));
609 }
610 _ => {
611 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
612 }
613 }
614 }
615}
616
617fn escape_yaml_str(s: &str) -> String {
618 let mut escaped = String::with_capacity(s.len());
619 for character in s.chars() {
620 match character {
621 '\\' => escaped.push_str("\\\\"),
622 '"' => escaped.push_str("\\\""),
623 '\n' => escaped.push_str("\\n"),
624 '\r' => escaped.push_str("\\r"),
625 '\t' => escaped.push_str("\\t"),
626 '\x08' => escaped.push_str("\\b"),
627 '\x0c' => escaped.push_str("\\f"),
628 '\x0b' => escaped.push_str("\\v"),
629 '\0' => escaped.push_str("\\0"),
630 control if control <= '\u{001f}' => {
631 use std::fmt::Write as _;
632 let _ = write!(escaped, "\\u{:04x}", control as u32);
633 }
634 other => escaped.push(other),
635 }
636 }
637 escaped
638}
639
640fn yaml_key(key: &str) -> String {
641 if is_safe_key(key) && !is_ambiguous_yaml_key(key) {
642 key.to_string()
643 } else {
644 format!("\"{}\"", escape_yaml_str(key))
645 }
646}
647
648fn is_ambiguous_yaml_key(key: &str) -> bool {
649 let lower = key.to_ascii_lowercase();
650 matches!(
651 lower.as_str(),
652 "true" | "false" | "null" | "~" | ".nan" | ".inf" | "+.inf" | "-.inf"
653 ) || matches!(
654 lower.strip_prefix(['+', '-']).unwrap_or(&lower),
655 "inf" | "infinity" | "nan"
656 ) || is_decimal_yaml_key(key)
657}
658
659fn is_decimal_yaml_key(key: &str) -> bool {
660 let mut bytes = key.bytes().peekable();
661 if matches!(bytes.peek(), Some(b'+' | b'-')) {
662 bytes.next();
663 }
664 let mut digits = 0;
665 while bytes.next_if(u8::is_ascii_digit).is_some() {
666 digits += 1;
667 }
668 if bytes.next_if_eq(&b'.').is_some() {
669 while bytes.next_if(u8::is_ascii_digit).is_some() {
670 digits += 1;
671 }
672 }
673 if digits == 0 {
674 return false;
675 }
676 if matches!(bytes.peek(), Some(b'e' | b'E')) {
677 bytes.next();
678 if matches!(bytes.peek(), Some(b'+' | b'-')) {
679 bytes.next();
680 }
681 let mut exponent_digits = 0;
682 while bytes.next_if(u8::is_ascii_digit).is_some() {
683 exponent_digits += 1;
684 }
685 if exponent_digits == 0 {
686 return false;
687 }
688 }
689 bytes.next().is_none()
690}
691
692fn quote_logfmt_key(key: &str) -> String {
693 if is_safe_key(key) {
694 key.to_string()
695 } else {
696 quote_logfmt_value(key)
697 }
698}
699
700fn is_safe_key(key: &str) -> bool {
701 !key.is_empty()
702 && key
703 .bytes()
704 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
705}
706
707fn yaml_scalar(value: &Value) -> String {
708 match value {
709 Value::String(s) => format!("\"{}\"", escape_yaml_str(s)),
710 Value::Null => "null".to_string(),
711 Value::Bool(b) => b.to_string(),
712 Value::Number(n) => format_number(n),
713 Value::Object(_) | Value::Array(_) => {
714 format!("\"{}\"", escape_yaml_str(&canonical_json(value)))
715 }
716 }
717}
718
719fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
724 if let Value::Object(map) = value {
725 let processed = process_object_fields(map);
726 for (display_key, v, formatted) in processed {
727 let full_key = if prefix.is_empty() {
728 display_key
729 } else {
730 format!("{}.{}", prefix, display_key)
731 };
732 if let Some(fv) = formatted {
733 pairs.push((full_key, fv));
734 } else {
735 match v {
736 Value::Object(map) if map.is_empty() => {
737 pairs.push((full_key, "{}".to_string()));
738 }
739 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
740 Value::Array(arr) if arr.is_empty() => {
741 pairs.push((full_key, "[]".to_string()));
742 }
743 Value::Array(arr) => {
744 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
745 pairs.push((full_key, joined));
746 }
747 Value::Null => pairs.push((full_key, String::new())),
748 _ => pairs.push((full_key, plain_scalar(v))),
749 }
750 }
751 }
752 }
753}
754
755fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
756 if let Value::Object(map) = value {
757 for key in sorted_value_keys(map) {
758 let v = &map[&key];
759 let full_key = if prefix.is_empty() {
760 key.clone()
761 } else {
762 format!("{}.{}", prefix, key)
763 };
764 match v {
765 Value::Object(map) if map.is_empty() => {
766 pairs.push((full_key, "{}".to_string()));
767 }
768 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
769 Value::Array(arr) if arr.is_empty() => {
770 pairs.push((full_key, "[]".to_string()));
771 }
772 Value::Array(arr) => {
773 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
774 pairs.push((full_key, joined));
775 }
776 Value::Null => pairs.push((full_key, String::new())),
777 _ => pairs.push((full_key, plain_scalar(v))),
778 }
779 }
780 }
781}
782
783fn plain_scalar(value: &Value) -> String {
784 match value {
785 Value::String(s) => s.clone(),
786 Value::Null => "null".to_string(),
787 Value::Bool(b) => b.to_string(),
788 Value::Number(n) => format_number(n),
789 Value::Object(_) | Value::Array(_) => canonical_json(value),
790 }
791}
792
793fn quote_logfmt_value(value: &str) -> String {
794 if value.is_empty() {
795 return String::new();
796 }
797 if !value.chars().any(|c| {
798 matches!(
799 c,
800 ' ' | '\t' | '\n' | '\r' | '\x0c' | '\x0b' | '\u{a0}' | '=' | '"' | '\\'
801 )
802 }) {
803 return value.to_string();
804 }
805 let escaped = value
806 .replace('\\', "\\\\")
807 .replace('"', "\\\"")
808 .replace('\n', "\\n")
809 .replace('\r', "\\r")
810 .replace('\t', "\\t")
811 .replace('\x0c', "\\f")
812 .replace('\x0b', "\\v");
813 format!("\"{}\"", escaped)
814}
815
816fn canonical_json(value: &Value) -> String {
817 serde_json::to_string(&sort_json_value(value))
818 .unwrap_or_else(|_| "<unsupported:json>".to_string())
819}
820
821fn sort_json_value(value: &Value) -> Value {
822 match value {
823 Value::Object(map) => {
824 let mut out = serde_json::Map::new();
825 for key in sorted_value_keys(map) {
826 if let Some(v) = map.get(&key) {
827 out.insert(key, sort_json_value(v));
828 }
829 }
830 Value::Object(out)
831 }
832 Value::Array(arr) => Value::Array(arr.iter().map(sort_json_value).collect()),
833 _ => value.clone(),
834 }
835}
836
837fn sorted_value_keys(map: &serde_json::Map<String, Value>) -> Vec<String> {
838 let mut keys: Vec<String> = map.keys().cloned().collect();
839 keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
840 keys
841}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846 use crate::redaction::{RedactionPolicy, Redactor};
847 use serde_json::json;
848
849 fn plain(value: &Value, policy: RedactionPolicy) -> String {
850 render(
851 value,
852 OutputFormat::Plain,
853 &OutputOptions {
854 redaction: Redactor::new().policy(policy),
855 style: PlainStyle::Readable,
856 },
857 )
858 }
859
860 #[test]
863 fn plain_strips_secret_suffix_once_the_value_is_redacted() {
864 assert_eq!(
865 plain(
866 &json!({"api_key_secret": "sk-live-xxx"}),
867 RedactionPolicy::All
868 ),
869 "api_key=***"
870 );
871 }
872
873 #[test]
874 fn plain_keeps_secret_suffix_when_redaction_is_off() {
875 assert_eq!(
878 plain(
879 &json!({"api_key_secret": "sk-live-xxx"}),
880 RedactionPolicy::Off
881 ),
882 "api_key_secret=sk-live-xxx"
883 );
884 assert_eq!(
885 plain(
886 &json!({"API_KEY_SECRET": "sk-live-xxx"}),
887 RedactionPolicy::Off
888 ),
889 "API_KEY_SECRET=sk-live-xxx"
890 );
891 }
892
893 #[test]
894 fn plain_keeps_secret_suffix_outside_trace_under_trace_only() {
895 assert_eq!(
896 plain(
897 &json!({
898 "api_key_secret": "sk-live-xxx",
899 "trace": {"request_secret": "top-secret"}
900 }),
901 RedactionPolicy::TraceOnly
902 ),
903 "api_key_secret=sk-live-xxx trace.request=***"
904 );
905 }
906
907 #[test]
908 fn plain_keeps_secret_suffix_on_an_unredacted_subtree() {
909 assert_eq!(
910 plain(
911 &json!({"db_secret": {"password": "hunter2"}}),
912 RedactionPolicy::Off
913 ),
914 "db_secret.password=hunter2"
915 );
916 assert_eq!(
917 plain(
918 &json!({"db_secret": {"password": "hunter2"}}),
919 RedactionPolicy::All
920 ),
921 "db=***"
922 );
923 }
924
925 #[test]
926 fn plain_unstripped_secret_key_does_not_collide_with_its_stem() {
927 assert_eq!(
930 plain(
931 &json!({"api_key": "public", "api_key_secret": "sk-live-xxx"}),
932 RedactionPolicy::Off
933 ),
934 "api_key=public api_key_secret=sk-live-xxx"
935 );
936 }
937}