use log::{debug, log_enabled, Level};
pub const TARGET: &str = "hotdata::http";
const SENSITIVE_KEYS: &[&str] = &[
"authorization",
"api_token",
"api_key",
"access_token",
"refresh_token",
"token",
"client_secret",
"secret",
"secret_value",
"value",
"password",
"passwd",
"private_key",
"credentials",
"connection_string",
];
const REDACTED: &str = "<redacted>";
const MAX_BODY_LEN: usize = 4096;
fn enabled() -> bool {
log_enabled!(target: TARGET, Level::Debug)
}
pub fn mask_credential(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let n = chars.len();
let head = |k: usize| -> String { chars[..k].iter().collect() };
if n >= 12 {
let tail: String = chars[n - 4..].iter().collect();
format!("{}...{}", head(4), tail)
} else if n > 4 {
format!("{}...", head(4))
} else {
"***".into()
}
}
fn is_sensitive(key: &str) -> bool {
SENSITIVE_KEYS.iter().any(|k| key.eq_ignore_ascii_case(k))
}
fn mask_auth_value(value: &str) -> String {
if let Some(token) = value.strip_prefix("Bearer ") {
format!("Bearer {}", mask_credential(token))
} else {
mask_credential(value)
}
}
pub fn log_request(req: &reqwest::Request) {
if !enabled() {
return;
}
debug!(target: TARGET, ">>> {} {}", req.method(), req.url());
for (name, value) in req.headers() {
let key = name.as_str();
let shown = match value.to_str() {
Ok(v) if key.eq_ignore_ascii_case("authorization") => mask_auth_value(v),
Ok(v) => v.to_string(),
Err(_) => "<non-utf8>".to_string(),
};
debug!(target: TARGET, " {key}: {shown}");
}
match req.body().and_then(reqwest::Body::as_bytes) {
Some(bytes) if !bytes.is_empty() => debug!(target: TARGET, "{}", redact_body(bytes)),
Some(_) => {}
None if req.body().is_some() => debug!(target: TARGET, "[streaming body]"),
None => {}
}
}
pub fn log_response_status(status: reqwest::StatusCode) {
if !enabled() {
return;
}
debug!(
target: TARGET,
"<<< {} {}",
status.as_u16(),
status.canonical_reason().unwrap_or("")
);
}
pub fn log_response_body(body: &str) {
if !enabled() || body.is_empty() {
return;
}
debug!(target: TARGET, "{}", redact_body(body.as_bytes()));
}
fn redact_body(bytes: &[u8]) -> String {
let text = match std::str::from_utf8(bytes) {
Ok(t) => t,
Err(_) => return format!("[binary: {} bytes]", bytes.len()),
};
let rendered = if let Ok(mut value) = serde_json::from_str::<serde_json::Value>(text) {
redact_json(&mut value);
serde_json::to_string(&value).unwrap_or_else(|_| text.to_string())
} else if let Some(form) = redact_form(text) {
form
} else {
text.to_string()
};
truncate(&rendered)
}
fn redact_json(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for (key, val) in map.iter_mut() {
if is_sensitive(key) {
*val = redacted_value(val);
} else {
redact_json(val);
}
}
}
serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
_ => {}
}
}
fn redacted_value(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) => serde_json::Value::String(mask_credential(s)),
serde_json::Value::Null => serde_json::Value::Null,
_ => serde_json::Value::String(REDACTED.to_string()),
}
}
fn redact_form(text: &str) -> Option<String> {
let segments: Vec<&str> = text.split('&').collect();
let looks_like_form = segments.iter().all(|seg| {
seg.split_once('=')
.is_some_and(|(k, _)| !k.is_empty() && !k.contains(char::is_whitespace))
});
if !looks_like_form {
return None;
}
let redacted = segments
.iter()
.map(|seg| match seg.split_once('=') {
Some((k, v)) if is_sensitive(k) => format!("{k}={}", mask_credential(v)),
_ => seg.to_string(),
})
.collect::<Vec<_>>()
.join("&");
Some(redacted)
}
fn truncate(text: &str) -> String {
if text.len() <= MAX_BODY_LEN {
return text.to_string();
}
let mut end = MAX_BODY_LEN;
while !text.is_char_boundary(end) {
end -= 1;
}
format!("{}… [{} bytes total]", &text[..end], text.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mask_credential_long_shows_head_and_tail() {
assert_eq!(mask_credential("abcdefghijkl"), "abcd...ijkl");
assert_eq!(
mask_credential("hd_0123456789abcdef"),
"hd_0...cdef"
);
}
#[test]
fn mask_credential_medium_shows_head_only() {
assert_eq!(mask_credential("abcdef"), "abcd...");
}
#[test]
fn mask_credential_short_is_fully_hidden() {
assert_eq!(mask_credential("abcd"), "***");
assert_eq!(mask_credential(""), "***");
}
#[test]
fn mask_auth_preserves_bearer_scheme() {
assert_eq!(
mask_auth_value("Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature"),
"Bearer eyJh...ture"
);
assert_eq!(mask_auth_value("Basic dXNlcjpwYXNz"), "Basi...YXNz");
}
#[test]
fn json_body_masks_sensitive_keys_recursively() {
let body = serde_json::json!({
"name": "prod-db",
"secret": "supersecretvalue123",
"nested": { "api_token": "hd_abcdef0123456789", "keep": "visible" },
"list": [ { "password": "hunter2hunter2" } ]
})
.to_string();
let out = redact_body(body.as_bytes());
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["name"], "prod-db");
assert_eq!(v["secret"], "supe...e123");
assert_eq!(v["nested"]["api_token"], "hd_a...6789");
assert_eq!(v["nested"]["keep"], "visible");
assert_eq!(v["list"][0]["password"], "hunt...ter2");
assert!(!out.contains("supersecretvalue123"));
assert!(!out.contains("hd_abcdef0123456789"));
}
#[test]
fn sensitive_object_value_is_fully_redacted() {
let body = serde_json::json!({
"credentials": { "password": "p4ssw0rd", "nested": { "token": "tkn" } },
"secret": ["leak-a", "leak-b"],
"keep": "visible"
})
.to_string();
let out = redact_body(body.as_bytes());
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["credentials"], "<redacted>");
assert_eq!(v["secret"], "<redacted>");
assert_eq!(v["keep"], "visible");
for leak in ["p4ssw0rd", "tkn", "leak-a", "leak-b"] {
assert!(!out.contains(leak), "leaked {leak} via structured value:\n{out}");
}
}
#[test]
fn secret_value_and_api_key_fields_are_masked() {
let body = serde_json::json!({
"name": "openai-key",
"value": "supersecretvalue123",
"api_key": "sk-abcdef0123456789"
})
.to_string();
let out = redact_body(body.as_bytes());
assert!(!out.contains("supersecretvalue123"), "secret value leaked:\n{out}");
assert!(!out.contains("sk-abcdef0123456789"), "api_key leaked:\n{out}");
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["name"], "openai-key");
assert_eq!(v["value"], "supe...e123");
}
#[test]
fn non_ascii_secret_value_does_not_panic() {
let secret = "naïve—café—señor—secret—üñ";
let body = serde_json::json!({ "secret": secret }).to_string();
let out = redact_body(body.as_bytes());
assert!(!out.contains(secret), "non-ascii secret leaked:\n{out}");
let _ = mask_credential(secret);
assert_eq!(mask_credential("héllo wörld!"), "héll...rld!");
}
#[test]
fn form_body_masks_sensitive_fields() {
let body = "grant_type=api_token&api_token=hd_0123456789abcdef&client_id=hotdata-rust-sdk";
let out = redact_body(body.as_bytes());
assert!(out.contains("grant_type=api_token"));
assert!(out.contains("client_id=hotdata-rust-sdk"));
assert!(out.contains("api_token=hd_0...cdef"));
assert!(!out.contains("hd_0123456789abcdef"));
}
#[test]
fn non_form_text_is_logged_verbatim() {
let body = "this is not a form body";
assert_eq!(redact_body(body.as_bytes()), body);
}
#[test]
fn binary_body_reports_byte_count() {
let out = redact_body(&[0xff, 0xfe, 0x00, 0x01]);
assert_eq!(out, "[binary: 4 bytes]");
}
#[test]
fn overlong_plain_body_is_truncated() {
let body = "x".repeat(MAX_BODY_LEN + 100);
let out = redact_body(body.as_bytes());
assert!(out.len() < body.len());
assert!(out.contains("bytes total]"));
}
#[test]
fn overlong_json_body_is_truncated() {
let big = "y".repeat(MAX_BODY_LEN * 2);
let body = serde_json::json!({ "rows": big }).to_string();
assert!(body.len() > MAX_BODY_LEN);
let out = redact_body(body.as_bytes());
assert!(out.len() <= MAX_BODY_LEN + 64, "json body not capped: {} bytes", out.len());
assert!(out.contains("bytes total]"));
}
}