1use alloc::string::{String, ToString};
5use core::time::Duration;
6
7use serde_json::Value;
8
9#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum Error {
17 #[error("unauthorized (HTTP {status}{})", fmt_code(code.as_deref()))]
22 Unauthorized {
23 status: u16,
25 code: Option<String>,
28 },
29 #[error("forbidden (HTTP {status}{})", fmt_code(code.as_deref()))]
33 Forbidden {
34 status: u16,
36 code: Option<String>,
39 },
40 #[error("rate limited{}", fmt_retry(*retry_after))]
42 RateLimited {
43 retry_after: Option<Duration>,
46 },
47 #[error("HTTP {status}{}", fmt_code(code.as_deref()))]
49 Http {
50 status: u16,
52 code: Option<String>,
54 message: Option<String>,
57 },
58 #[error("unexpected response (HTTP {status}): {message}")]
60 Decode {
61 status: u16,
63 message: String,
65 snippet: String,
70 },
71 #[error("transport: {0}")]
74 Transport(#[source] reqwest::Error),
75 #[error("response body over {limit} bytes")]
77 TooLarge {
78 limit: usize,
80 },
81 #[error("the refresh task was cancelled")]
84 Cancelled,
85 #[error("config: {0}")]
87 Config(String),
88}
89
90impl Error {
91 pub fn is_unauthorized(&self) -> bool {
94 matches!(self, Self::Unauthorized { .. })
95 }
96
97 pub fn is_forbidden(&self) -> bool {
100 matches!(self, Self::Forbidden { .. })
101 }
102
103 pub fn is_transient(&self) -> bool {
106 match self {
107 Self::RateLimited { .. } | Self::Transport(_) => true,
108 Self::Http { status, .. } => *status >= 500,
109 _ => false,
110 }
111 }
112}
113
114fn fmt_code(code: Option<&str>) -> String {
115 code.map(|c| alloc::format!(", {}", scrub_text(c, 64)))
116 .unwrap_or_default()
117}
118
119fn fmt_retry(retry_after: Option<Duration>) -> String {
120 retry_after
121 .map(|d| alloc::format!(", retry after {}s", d.as_secs()))
122 .unwrap_or_default()
123}
124
125pub(crate) const SNIPPET_LEN: usize = 256;
127const MESSAGE_LEN: usize = 200;
128const REDACTED: &str = "<redacted>";
129
130const SECRET_KEYS: &[&str] = &[
135 "token",
136 "secret",
137 "password",
138 "key",
139 "code",
140 "authorization",
141 "cookie",
142 "credential",
143 "email",
144 "name",
145 "phone",
146 "address",
147 "birth",
148];
149
150pub(crate) fn error_fields(body: &[u8]) -> (Option<String>, Option<String>) {
154 let Ok(Value::Object(o)) = serde_json::from_slice::<Value>(body) else {
155 return (None, None);
156 };
157 let s = |v: Option<&Value>, n| v.and_then(Value::as_str).map(|s| scrub_text(s, n));
158 match o.get("error") {
159 Some(Value::String(c)) => (
160 Some(scrub_text(c, 64)),
161 s(o.get("error_description"), MESSAGE_LEN),
162 ),
163 Some(Value::Object(e)) => (s(e.get("type"), 64), s(e.get("message"), MESSAGE_LEN)),
164 _ => (None, None),
165 }
166}
167
168pub(crate) fn scrub_serde(e: &serde_json::Error) -> String {
171 let text = e.to_string();
172 let mut out = String::with_capacity(text.len());
173 let mut parts = text.split('"');
174 if let Some(first) = parts.next() {
175 out.push_str(first);
176 }
177 for (i, part) in parts.enumerate() {
178 out.push('"');
179 out.push_str(if i % 2 == 0 { REDACTED } else { part });
180 }
181 out
182}
183
184pub(crate) fn snippet(body: &[u8]) -> String {
188 match serde_json::from_slice::<Value>(body) {
189 Ok(mut v) => {
190 scrub_value(&mut v);
191 truncate(&v.to_string(), SNIPPET_LEN)
192 }
193 Err(_) => scrub_text(&String::from_utf8_lossy(body), SNIPPET_LEN),
194 }
195}
196
197fn scrub_value(v: &mut Value) {
198 match v {
199 Value::Object(o) => {
200 for (k, val) in o.iter_mut() {
201 let k = k.to_ascii_lowercase();
202 if SECRET_KEYS.iter().any(|s| k.contains(s)) && !val.is_null() {
203 *val = Value::String(REDACTED.into());
204 } else {
205 scrub_value(val);
206 }
207 }
208 }
209 Value::Array(a) => a.iter_mut().for_each(scrub_value),
210 Value::String(s) if looks_like_token(s) || looks_like_email(s) => *s = REDACTED.into(),
211 _ => {}
212 }
213}
214
215pub(crate) fn scrub_text(text: &str, max: usize) -> String {
217 let words: alloc::vec::Vec<&str> = text
218 .split_inclusive(char::is_whitespace)
219 .map(|w| {
220 if looks_like_token(w.trim()) || looks_like_email(w) {
221 REDACTED
222 } else {
223 w
224 }
225 })
226 .collect();
227 truncate(&words.concat(), max)
228}
229
230fn looks_like_token(s: &str) -> bool {
233 let s = s.trim_matches(|c: char| matches!(c, '"' | '\'' | ',' | ';'));
234 s.contains("sk-ant-")
235 || s.to_ascii_lowercase().starts_with("bearer")
236 || (s.len() >= 32
237 && s.chars().all(|c| {
238 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~' | '+' | '/' | '=')
239 }))
240}
241
242fn looks_like_email(s: &str) -> bool {
244 s.contains('@')
245}
246
247fn truncate(s: &str, max: usize) -> String {
248 match s.char_indices().nth(max) {
249 Some((i, _)) => {
250 let mut out = s[..i].to_string();
251 out.push('…');
252 out
253 }
254 None => s.to_string(),
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn snippet_redacts_token_keys_and_values() {
264 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"}"#;
265 let s = snippet(body);
266 assert!(!s.contains("sk-ant"), "{s}");
267 assert!(!s.contains("\"r\""), "{s}");
268 assert!(s.contains("\"expires_in\":\"soon\""), "{s}");
269 assert!(s.contains("\"ok\":\"short\""), "{s}");
270 }
271
272 #[test]
273 fn snippet_redacts_personal_data() {
274 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"}"#;
275 let s = snippet(body);
276 for leak in ["ada@", "Ada", "Example", "555", "x@example"] {
277 assert!(!s.contains(leak), "{leak}: {s}");
278 }
279 assert!(s.contains(r#""billing_type":"stripe""#), "{s}");
280 assert!(s.contains(r#""uuid":"u1""#), "{s}");
281 }
282
283 #[test]
284 fn snippet_of_text_is_cut_and_scrubbed() {
285 let body = alloc::format!(
286 "<html>sk-ant-oat01-fake ada@example.com {}</html>",
287 "x".repeat(400)
288 );
289 let s = snippet(body.as_bytes());
290 assert!(!s.contains("sk-ant"), "{s}");
291 assert!(!s.contains("ada@"), "{s}");
292 assert!(s.chars().count() <= SNIPPET_LEN + 1);
293 }
294
295 #[test]
296 fn serde_messages_lose_quoted_values() {
297 #[derive(Debug, serde::Deserialize)]
298 #[allow(dead_code)]
299 struct T {
300 n: u64,
301 }
302 let e = serde_json::from_str::<T>(r#"{"n":"sk-ant-oat01-fake"}"#).unwrap_err();
303 let m = scrub_serde(&e);
304 assert!(!m.contains("sk-ant"), "{m}");
305 assert!(m.contains("invalid type"), "{m}");
306 }
307
308 #[test]
309 fn error_fields_both_shapes() {
310 assert_eq!(
311 error_fields(
312 br#"{"error":"invalid_grant","error_description":"Refresh token not found"}"#
313 ),
314 (
315 Some("invalid_grant".into()),
316 Some("Refresh token not found".into())
317 )
318 );
319 assert_eq!(
320 error_fields(
321 br#"{"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}"#
322 ),
323 (Some("rate_limit_error".into()), Some("slow down".into()))
324 );
325 assert_eq!(error_fields(b"nope"), (None, None));
326 }
327
328 #[test]
329 fn display() {
330 let e = Error::Unauthorized {
331 status: 400,
332 code: Some("invalid_grant".into()),
333 };
334 assert_eq!(e.to_string(), "unauthorized (HTTP 400, invalid_grant)");
335 let e = Error::RateLimited {
336 retry_after: Some(Duration::from_secs(30)),
337 };
338 assert_eq!(e.to_string(), "rate limited, retry after 30s");
339 assert!(e.is_transient());
340 }
341}