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