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