use alloc::string::{String, ToString};
use core::time::Duration;
use serde_json::Value;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("unauthorized (HTTP {status}{})", fmt_code(code.as_deref()))]
Unauthorized {
status: u16,
code: Option<String>,
},
#[error("forbidden (HTTP {status}{})", fmt_code(code.as_deref()))]
Forbidden {
status: u16,
code: Option<String>,
},
#[error("rate limited{}", fmt_retry(*retry_after))]
RateLimited {
retry_after: Option<Duration>,
},
#[error("HTTP {status}{}", fmt_code(code.as_deref()))]
Http {
status: u16,
code: Option<String>,
message: Option<String>,
},
#[error("unexpected response (HTTP {status}): {message}")]
Decode {
status: u16,
message: String,
snippet: String,
},
#[error("transport: {0}")]
Transport(#[source] reqwest::Error),
#[error("response body over {limit} bytes")]
TooLarge {
limit: usize,
},
#[error("the refresh task was cancelled")]
Cancelled,
#[error("config: {0}")]
Config(String),
}
impl Error {
pub fn is_unauthorized(&self) -> bool {
matches!(self, Self::Unauthorized { .. })
}
pub fn is_forbidden(&self) -> bool {
matches!(self, Self::Forbidden { .. })
}
pub fn is_transient(&self) -> bool {
match self {
Self::RateLimited { .. } | Self::Transport(_) => true,
Self::Http { status, .. } => *status >= 500,
_ => false,
}
}
}
fn fmt_code(code: Option<&str>) -> String {
code.map(|c| alloc::format!(", {}", scrub_text(c, 64)))
.unwrap_or_default()
}
fn fmt_retry(retry_after: Option<Duration>) -> String {
retry_after
.map(|d| alloc::format!(", retry after {}s", d.as_secs()))
.unwrap_or_default()
}
pub(crate) const SNIPPET_LEN: usize = 256;
const MESSAGE_LEN: usize = 200;
const REDACTED: &str = "<redacted>";
const SECRET_KEYS: &[&str] = &[
"token",
"secret",
"password",
"key",
"code",
"authorization",
"cookie",
"credential",
"email",
"name",
"phone",
"address",
"birth",
];
pub(crate) fn error_fields(body: &[u8]) -> (Option<String>, Option<String>) {
let Ok(Value::Object(o)) = serde_json::from_slice::<Value>(body) else {
return (None, None);
};
let s = |v: Option<&Value>, n| v.and_then(Value::as_str).map(|s| scrub_text(s, n));
match o.get("error") {
Some(Value::String(c)) => (
Some(scrub_text(c, 64)),
s(o.get("error_description"), MESSAGE_LEN),
),
Some(Value::Object(e)) => (s(e.get("type"), 64), s(e.get("message"), MESSAGE_LEN)),
_ => (None, None),
}
}
pub(crate) fn scrub_serde(e: &serde_json::Error) -> String {
let text = e.to_string();
let mut out = String::with_capacity(text.len());
let mut parts = text.split('"');
if let Some(first) = parts.next() {
out.push_str(first);
}
for (i, part) in parts.enumerate() {
out.push('"');
out.push_str(if i % 2 == 0 { REDACTED } else { part });
}
out
}
pub(crate) fn snippet(body: &[u8]) -> String {
match serde_json::from_slice::<Value>(body) {
Ok(mut v) => {
scrub_value(&mut v);
truncate(&v.to_string(), SNIPPET_LEN)
}
Err(_) => scrub_text(&String::from_utf8_lossy(body), SNIPPET_LEN),
}
}
fn scrub_value(v: &mut Value) {
match v {
Value::Object(o) => {
for (k, val) in o.iter_mut() {
let k = k.to_ascii_lowercase();
if SECRET_KEYS.iter().any(|s| k.contains(s)) && !val.is_null() {
*val = Value::String(REDACTED.into());
} else {
scrub_value(val);
}
}
}
Value::Array(a) => a.iter_mut().for_each(scrub_value),
Value::String(s) if looks_like_token(s) || looks_like_email(s) => *s = REDACTED.into(),
_ => {}
}
}
pub(crate) fn scrub_text(text: &str, max: usize) -> String {
let words: alloc::vec::Vec<&str> = text
.split_inclusive(char::is_whitespace)
.map(|w| {
if looks_like_token(w.trim()) || looks_like_email(w) {
REDACTED
} else {
w
}
})
.collect();
truncate(&words.concat(), max)
}
fn looks_like_token(s: &str) -> bool {
let s = s.trim_matches(|c: char| matches!(c, '"' | '\'' | ',' | ';'));
s.contains("sk-ant-")
|| s.to_ascii_lowercase().starts_with("bearer")
|| (s.len() >= 32
&& s.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~' | '+' | '/' | '=')
}))
}
fn looks_like_email(s: &str) -> bool {
s.contains('@')
}
fn truncate(s: &str, max: usize) -> String {
match s.char_indices().nth(max) {
Some((i, _)) => {
let mut out = s[..i].to_string();
out.push('…');
out
}
None => s.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snippet_redacts_token_keys_and_values() {
let body = br#"{"access_token":"sk-ant-oat01-fake","refresh_token":"r","expires_in":"soon","note":"sk-ant-ort01-other","nested":{"api_key":"k"},"ok":"short"}"#;
let s = snippet(body);
assert!(!s.contains("sk-ant"), "{s}");
assert!(!s.contains("\"r\""), "{s}");
assert!(s.contains("\"expires_in\":\"soon\""), "{s}");
assert!(s.contains("\"ok\":\"short\""), "{s}");
}
#[test]
fn snippet_redacts_personal_data() {
let body = br#"{"account":{"uuid":"u1","email_address":"ada@example.com","full_name":"Ada Example","display_name":"Ada","phone_number":"+1 555 0100"},"organization":{"name":"Example Org","billing_type":"stripe"},"email":"x@example.org","roles":{"organization_name":"Example Org"},"note":"mail ada@example.com"}"#;
let s = snippet(body);
for leak in ["ada@", "Ada", "Example", "555", "x@example"] {
assert!(!s.contains(leak), "{leak}: {s}");
}
assert!(s.contains(r#""billing_type":"stripe""#), "{s}");
assert!(s.contains(r#""uuid":"u1""#), "{s}");
}
#[test]
fn snippet_of_text_is_cut_and_scrubbed() {
let body = alloc::format!(
"<html>sk-ant-oat01-fake ada@example.com {}</html>",
"x".repeat(400)
);
let s = snippet(body.as_bytes());
assert!(!s.contains("sk-ant"), "{s}");
assert!(!s.contains("ada@"), "{s}");
assert!(s.chars().count() <= SNIPPET_LEN + 1);
}
#[test]
fn serde_messages_lose_quoted_values() {
#[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
struct T {
n: u64,
}
let e = serde_json::from_str::<T>(r#"{"n":"sk-ant-oat01-fake"}"#).unwrap_err();
let m = scrub_serde(&e);
assert!(!m.contains("sk-ant"), "{m}");
assert!(m.contains("invalid type"), "{m}");
}
#[test]
fn error_fields_both_shapes() {
assert_eq!(
error_fields(
br#"{"error":"invalid_grant","error_description":"Refresh token not found"}"#
),
(
Some("invalid_grant".into()),
Some("Refresh token not found".into())
)
);
assert_eq!(
error_fields(
br#"{"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}"#
),
(Some("rate_limit_error".into()), Some("slow down".into()))
);
assert_eq!(error_fields(b"nope"), (None, None));
}
#[test]
fn display() {
let e = Error::Unauthorized {
status: 400,
code: Some("invalid_grant".into()),
};
assert_eq!(e.to_string(), "unauthorized (HTTP 400, invalid_grant)");
let e = Error::RateLimited {
retry_after: Some(Duration::from_secs(30)),
};
assert_eq!(e.to_string(), "rate limited, retry after 30s");
assert!(e.is_transient());
}
}