use std::borrow::Cow;
use serde_json::Value;
pub(crate) const SENSITIVE_KEYS: &[&str] = &[
"access_token",
"refresh_token",
"client_secret",
"signature",
"password",
];
pub(crate) const REDACTION_MARKER: &str = "***";
pub(crate) const MAX_PAYLOAD_DISPLAY_LEN: usize = 512;
#[must_use]
pub(crate) fn redact_params(value: Value) -> Value {
match value {
Value::Object(map) => {
let redacted = map
.into_iter()
.map(|(key, v)| {
if is_sensitive_key(&key) {
(key, Value::String(REDACTION_MARKER.to_owned()))
} else {
(key, redact_params(v))
}
})
.collect();
Value::Object(redacted)
}
Value::Array(items) => Value::Array(items.into_iter().map(redact_params).collect()),
other => other,
}
}
pub(crate) const NON_JSON_PLACEHOLDER: &str = "<non-json response redacted>";
#[must_use]
pub(crate) fn redact_raw_response(raw: &str) -> String {
match serde_json::from_str::<Value>(raw) {
Ok(value) => {
let redacted = redact_params(value);
serde_json::to_string(&redacted).unwrap_or_else(|_| NON_JSON_PLACEHOLDER.to_owned())
}
Err(_) => NON_JSON_PLACEHOLDER.to_owned(),
}
}
#[must_use]
pub(crate) fn truncate_for_display(s: &str) -> Cow<'_, str> {
match s.char_indices().nth(MAX_PAYLOAD_DISPLAY_LEN) {
Some((byte_idx, _)) => {
let mut truncated = String::with_capacity(byte_idx.saturating_add('…'.len_utf8()));
truncated.push_str(&s[..byte_idx]);
truncated.push('…');
Cow::Owned(truncated)
}
None => Cow::Borrowed(s),
}
}
#[inline]
fn is_sensitive_key(key: &str) -> bool {
SENSITIVE_KEYS
.iter()
.any(|sensitive| key.eq_ignore_ascii_case(sensitive))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn redact_params_scalar_passthrough() {
assert_eq!(redact_params(Value::Null), Value::Null);
assert_eq!(redact_params(json!(42)), json!(42));
assert_eq!(redact_params(json!(true)), json!(true));
assert_eq!(redact_params(json!("plain")), json!("plain"));
}
#[test]
fn redact_params_redacts_top_level_access_token() {
let input = json!({ "access_token": "secret-token", "other": 1 });
let out = redact_params(input);
assert_eq!(out, json!({ "access_token": "***", "other": 1 }));
}
#[test]
fn redact_params_redacts_refresh_token() {
let input = json!({ "refresh_token": "abc" });
assert_eq!(redact_params(input), json!({ "refresh_token": "***" }));
}
#[test]
fn redact_params_redacts_client_secret() {
let input = json!({ "client_secret": "s3cret" });
assert_eq!(redact_params(input), json!({ "client_secret": "***" }));
}
#[test]
fn redact_params_redacts_signature() {
let input = json!({ "signature": "deadbeef" });
assert_eq!(redact_params(input), json!({ "signature": "***" }));
}
#[test]
fn redact_params_redacts_password() {
let input = json!({ "password": "hunter2" });
assert_eq!(redact_params(input), json!({ "password": "***" }));
}
#[test]
fn redact_params_redacts_case_insensitive_variants() {
let input = json!({
"Password": "a",
"PASSWORD": "b",
"Access_Token": "c",
"REFRESH_TOKEN": "d",
"Client_Secret": "e",
"Signature": "f",
});
let out = redact_params(input);
assert_eq!(
out,
json!({
"Password": "***",
"PASSWORD": "***",
"Access_Token": "***",
"REFRESH_TOKEN": "***",
"Client_Secret": "***",
"Signature": "***",
})
);
}
#[test]
fn redact_params_does_not_redact_structurally_different_keys() {
let input = json!({
"AccessToken": "keep-me",
"clientsecret": "keep-me-too",
});
let out = redact_params(input.clone());
assert_eq!(out, input);
}
#[test]
fn redact_params_redacts_nested_object() {
let input = json!({
"auth": {
"client_id": "public-id",
"client_secret": "shh",
"nested": { "password": "deeper" }
},
"data": 1
});
let out = redact_params(input);
assert_eq!(
out,
json!({
"auth": {
"client_id": "public-id",
"client_secret": "***",
"nested": { "password": "***" }
},
"data": 1
})
);
}
#[test]
fn redact_params_redacts_array_of_objects() {
let input = json!([
{ "access_token": "t1", "keep": 1 },
{ "access_token": "t2", "keep": 2 },
]);
let out = redact_params(input);
assert_eq!(
out,
json!([
{ "access_token": "***", "keep": 1 },
{ "access_token": "***", "keep": 2 },
])
);
}
#[test]
fn redact_params_leaves_non_sensitive_keys_alone() {
let input = json!({
"instrument_name": "BTC-PERPETUAL",
"amount": 10,
"type": "limit",
});
let out = redact_params(input.clone());
assert_eq!(out, input);
}
#[test]
fn redact_raw_response_valid_json_redacts() {
let raw = r#"{"access_token":"leak","id":1}"#;
let redacted = redact_raw_response(raw);
assert!(!redacted.contains("leak"));
assert!(redacted.contains("***"));
assert!(redacted.contains("\"id\":1"));
}
#[test]
fn redact_raw_response_invalid_json_replaced_with_placeholder() {
let raw = "not json at all";
assert_eq!(redact_raw_response(raw), NON_JSON_PLACEHOLDER);
}
#[test]
fn redact_raw_response_invalid_json_does_not_leak_sensitive_like_substrings() {
let raw = "password=hunter2 (raw log line)";
let out = redact_raw_response(raw);
assert_eq!(out, NON_JSON_PLACEHOLDER);
assert!(!out.contains("hunter2"));
}
#[test]
fn redact_raw_response_truncated_json_fails_closed() {
let raw = r#"{"access_token":"leak-me","id":1"#;
let out = redact_raw_response(raw);
assert_eq!(out, NON_JSON_PLACEHOLDER);
assert!(!out.contains("leak-me"));
}
#[test]
fn truncate_for_display_short_borrows() {
let s = "abc";
let out = truncate_for_display(s);
assert!(matches!(out, Cow::Borrowed(_)));
assert_eq!(out, "abc");
}
#[test]
fn truncate_for_display_long_truncates_at_char_boundary() {
let s: String = "x".repeat(MAX_PAYLOAD_DISPLAY_LEN + 100);
let out = truncate_for_display(&s);
assert!(matches!(out, Cow::Owned(_)));
assert_eq!(out.chars().count(), MAX_PAYLOAD_DISPLAY_LEN + 1);
assert!(out.ends_with('…'));
}
#[test]
fn truncate_for_display_multibyte_does_not_panic() {
let s: String = "€".repeat(MAX_PAYLOAD_DISPLAY_LEN + 10);
let out = truncate_for_display(&s);
assert_eq!(out.chars().count(), MAX_PAYLOAD_DISPLAY_LEN + 1);
}
#[test]
fn truncate_for_display_exact_cap_is_borrowed() {
let s: String = "x".repeat(MAX_PAYLOAD_DISPLAY_LEN);
let out = truncate_for_display(&s);
assert!(matches!(out, Cow::Borrowed(_)));
assert_eq!(out.chars().count(), MAX_PAYLOAD_DISPLAY_LEN);
}
}