const MAX_BODY: usize = 16_384;
const MAX_DIAGNOSTIC: usize = 768;
pub(super) async fn diagnostic(mut response: reqwest::Response) -> String {
let mut bytes = Vec::new();
loop {
match response.chunk().await {
Ok(Some(chunk)) if bytes.len() + chunk.len() <= MAX_BODY => {
bytes.extend_from_slice(&chunk)
}
Ok(Some(_)) => return "[response omitted: too large; truncated]".into(),
Ok(None) => return summarize(&bytes),
Err(_) => return "[response body could not be read]".into(),
}
}
}
fn summarize(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes);
let selected = match serde_json::from_slice::<serde_json::Value>(bytes) {
Ok(value) => diagnostic_fields(&value),
Err(_) if text.trim_start().starts_with(['{', '[']) => "[invalid JSON body omitted]".into(),
Err(_) => text.into_owned(),
};
let truncated = selected.chars().count() > MAX_DIAGNOSTIC;
let clean = scrub(&selected);
let mut summary: String = clean.chars().take(MAX_DIAGNOSTIC).collect();
if truncated || clean.chars().count() > MAX_DIAGNOSTIC {
summary.push_str(" [truncated]");
}
if summary.is_empty() {
summary.push_str("[no diagnostic body]");
}
summary
}
fn diagnostic_fields(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(text) => text.clone(),
serde_json::Value::Number(number) => number.to_string(),
serde_json::Value::Object(fields) => {
["error", "message", "reason", "code", "status", "detail"]
.iter()
.filter_map(|key| fields.get(*key).map(diagnostic_fields))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("; ")
}
_ => String::new(),
}
}
fn scrub(text: &str) -> String {
let text = crate::redact::urls(text);
let safe = redact_label_values(&text);
safe.split_whitespace()
.map(|word| {
let token = word.trim_matches(|c: char| !c.is_ascii_alphanumeric());
if token.len() > 128
|| (token.len() >= 64
&& token
.trim_start_matches("0x")
.chars()
.all(|c| c.is_ascii_hexdigit()))
{
"[redacted]".to_string()
} else {
word.chars().filter(|c| !c.is_control()).collect()
}
})
.collect::<Vec<_>>()
.join(" ")
}
const CREDENTIAL_LABELS: &[&str] = &[
"key",
"api_key",
"apikey",
"api-key",
"authorization",
"bearer",
"password",
"secret",
"token",
"auth",
"cookie",
"credential",
"private key",
"signature",
"envelope",
];
fn redact_label_values(text: &str) -> String {
let lower = text.to_ascii_lowercase();
let mut out = String::new();
let mut copied = 0;
for (index, _) in text.char_indices() {
if index < copied
|| text[..index]
.chars()
.next_back()
.is_some_and(|c| c.is_alphanumeric() || matches!(c, '_' | '-'))
{
continue;
}
for &label in CREDENTIAL_LABELS {
if !lower[index..].starts_with(label) {
continue;
}
let after = index + label.len();
let Some(offset) = label_value_start(&text[after..], label == "bearer") else {
continue;
};
let start = after + offset;
if start == text.len() {
continue;
}
out.push_str(&text[copied..start]);
out.push_str("[redacted]");
copied = value_end(text, start);
break;
}
}
out.push_str(&text[copied..]);
out
}
fn label_value_start(tail: &str, bearer: bool) -> Option<usize> {
let value = if bearer {
if !tail.starts_with(char::is_whitespace) {
return None;
}
tail.trim_start()
} else {
let tail = tail.strip_prefix(['"', '\'']).unwrap_or(tail).trim_start();
tail.strip_prefix(['=', ':'])?.trim_start()
};
Some(tail.len() - value.len())
}
fn value_end(text: &str, start: usize) -> usize {
let mut value = &text[start..];
if value
.get(..6)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("bearer"))
&& value[6..].starts_with(char::is_whitespace)
{
value = value[6..].trim_start();
}
let start = text.len() - value.len();
if value.starts_with(['{', '[']) {
let mut json = serde_json::Deserializer::from_str(value).into_iter::<serde_json::Value>();
return if matches!(json.next(), Some(Ok(_))) {
start + json.byte_offset()
} else {
text.len()
};
}
if let Some(quote) = value.chars().next().filter(|c| matches!(c, '"' | '\'')) {
let mut escaped = false;
for (index, c) in value[1..].char_indices() {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == quote {
return start + index + 2;
}
}
return text.len();
}
start
+ value
.find(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | '}' | ']'))
.unwrap_or(value.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_reasons_survive_and_only_label_values_are_redacted() {
for reason in [
"invalid signature",
"authentication failed",
"unsupported token",
"tokenization failed; monkey=banana; public_key=visible",
] {
assert_eq!(scrub(reason), reason);
}
for (text, expected) in [
(
"invalid signature; key=VALUE; try again",
"invalid signature; key=[redacted]; try again",
),
(
"authentication failed; auth: VALUE; try again",
"authentication failed; auth: [redacted]; try again",
),
(
"unsupported token; \"token\": \"VALUE\"; try again",
"unsupported token; \"token\": [redacted]; try again",
),
(
"auth = \"VALUE with spaces\"; invalid signature",
"auth = [redacted]; invalid signature",
),
(
"authorization: Bearer VALUE; unsupported token",
"authorization: [redacted]; unsupported token",
),
(
"signature: {\"r\":\"VALUE\"}; failed",
"signature: [redacted]; failed",
),
(
"\u{9519}\u{8bef}: \"auth\": \"\u{79d8}\u{5bc6}\"; failed",
"\u{9519}\u{8bef}: \"auth\": [redacted]; failed",
),
] {
assert_eq!(scrub(text), expected);
}
}
#[test]
fn diagnostics_keep_reasons_without_credentials_or_request_echoes() {
let body = serde_json::json!({"error": {"message": "invalid chain"},
"auth": "SYNTHETIC_SIGNATURE", "request": {"api_key": "SYNTHETIC_KEY"}, "code": 42});
assert_eq!(summarize(body.to_string().as_bytes()), "invalid chain; 42");
assert_eq!(
summarize(b"denied: Bearer SYNTHETIC"),
"denied: Bearer [redacted]"
);
assert_eq!(
summarize(b"denied: api_key=VALUE"),
"denied: api_key=[redacted]"
);
assert_eq!(summarize(b"{broken auth"), "[invalid JSON body omitted]");
assert_eq!(summarize(b""), "[no diagnostic body]");
assert!(!summarize(format!("signature {}", "ab".repeat(65)).as_bytes()).contains("abab"));
assert!(!summarize("ab".repeat(65).as_bytes()).contains("abab"));
assert!(summarize("reason ".repeat(300).as_bytes()).ends_with("[truncated]"));
}
}