jellytau_lib/utils/
diagnostics.rs1use std::borrow::Cow;
24
25pub const REDACTED: &str = "[REDACTED]";
27
28const SECRET_QUERY_KEYS: &[&str] = &["api_key", "apikey", "x-emby-token", "accesstoken"];
34
35const SECRET_HEADERS: &[&str] = &[
37 "x-emby-token",
38 "x-mediabrowser-token",
39 "authorization",
40 "x-emby-authorization",
41];
42
43const SECRET_JSON_KEYS: &[&str] = &["accesstoken", "password", "token"];
45
46pub fn redact(line: &str) -> String {
51 let mut out = redact_query_params(line);
52 out = redact_headers(&out);
53 out = redact_json_values(&out);
54 out = redact_emby_auth(&out);
55 out
56}
57
58fn redact_query_params(line: &str) -> String {
63 let mut result = String::with_capacity(line.len());
64 let lower = line.to_ascii_lowercase();
65 let bytes = line.as_bytes();
66 let mut i = 0;
67
68 while i < bytes.len() {
69 let mut matched = None;
70 for key in SECRET_QUERY_KEYS {
71 if lower[i..].starts_with(key) {
74 let prev = if i == 0 { None } else { Some(bytes[i - 1]) };
75 let is_param_start = matches!(prev, None | Some(b'?') | Some(b'&'));
76 let after = i + key.len();
77 if is_param_start && after < bytes.len() && bytes[after] == b'=' {
78 matched = Some((*key, after + 1));
79 break;
80 }
81 }
82 }
83
84 match matched {
85 Some((key, value_start)) => {
86 result.push_str(&line[i..i + key.len()]);
87 result.push('=');
88 result.push_str(REDACTED);
89 let mut end = value_start;
90 while end < bytes.len()
91 && !matches!(bytes[end], b'&' | b' ' | b'"' | b'\'' | b'\t' | b')')
92 {
93 end += 1;
94 }
95 i = end;
96 }
97 None => {
98 let ch = line[i..].chars().next().unwrap_or('\0');
101 result.push(ch);
102 i += ch.len_utf8();
103 }
104 }
105 }
106
107 result
108}
109
110fn redact_headers(line: &str) -> String {
117 let mut out = String::with_capacity(line.len());
118 let mut rest = line;
119
120 'outer: loop {
121 let lower = rest.to_ascii_lowercase();
122
123 let mut best: Option<(usize, usize)> = None;
126 for header in SECRET_HEADERS {
127 let needle = format!("{header}:");
128 if let Some(pos) = lower.find(&needle) {
129 let candidate = (pos, needle.len());
130 if best.is_none_or(|(best_pos, _)| pos < best_pos) {
131 best = Some(candidate);
132 }
133 }
134 }
135
136 let Some((pos, needle_len)) = best else {
137 break 'outer;
138 };
139
140 let value_start = pos + needle_len;
141 let value_end = rest[value_start..]
144 .find(',')
145 .map_or(rest.len(), |c| value_start + c);
146
147 out.push_str(&rest[..value_start]);
148 out.push(' ');
149 out.push_str(REDACTED);
150
151 rest = &rest[value_end..];
154 }
155
156 out.push_str(rest);
157 out
158}
159
160fn redact_json_values(line: &str) -> String {
162 let mut out = Cow::Borrowed(line);
163
164 for key in SECRET_JSON_KEYS {
165 loop {
166 let lower = out.to_ascii_lowercase();
167 let pattern = format!("\"{key}\"");
168 let Some(key_pos) = lower.find(&pattern) else {
169 break;
170 };
171
172 let after_key = key_pos + pattern.len();
174 let Some(colon_rel) = out[after_key..].find(':') else {
175 break;
176 };
177 let value_region = after_key + colon_rel + 1;
178 let Some(open_rel) = out[value_region..].find('"') else {
179 break;
180 };
181 let open = value_region + open_rel;
182 let Some(close_rel) = out[open + 1..].find('"') else {
183 break;
184 };
185 let close = open + 1 + close_rel;
186
187 if &out[open + 1..close] == REDACTED {
189 break;
190 }
191
192 let mut replaced = String::with_capacity(out.len());
193 replaced.push_str(&out[..open + 1]);
194 replaced.push_str(REDACTED);
195 replaced.push_str(&out[close..]);
196 out = Cow::Owned(replaced);
197 }
198 }
199
200 out.into_owned()
201}
202
203fn redact_emby_auth(line: &str) -> String {
207 let lower = line.to_ascii_lowercase();
208 let Some(pos) = lower.find("token=\"") else {
209 return line.to_string();
210 };
211 let open = pos + "token=\"".len();
212 let Some(close_rel) = line[open..].find('"') else {
213 return line.to_string();
214 };
215 let close = open + close_rel;
216 if &line[open..close] == REDACTED {
217 return line.to_string();
218 }
219 let mut out = String::with_capacity(line.len());
220 out.push_str(&line[..open]);
221 out.push_str(REDACTED);
222 out.push_str(&line[close..]);
223 out
224}
225
226pub fn redact_server_url(url: &str) -> String {
232 let Some(scheme_end) = url.find("://") else {
233 return REDACTED.to_string();
234 };
235 let after_scheme = scheme_end + 3;
236 let host_end = url[after_scheme..]
237 .find('/')
238 .map_or(url.len(), |slash| after_scheme + slash);
239 let host = &url[after_scheme..host_end];
241 let host = host.rsplit('@').next().unwrap_or(host);
242 format!("{}://{}", &url[..scheme_end], host)
243}
244
245pub fn install_panic_hook() {
256 let previous = std::panic::take_hook();
257 std::panic::set_hook(Box::new(move |info| {
258 let payload = panic_payload_string(info);
262 let location = info
263 .location()
264 .map(|l| format!("{}:{}", l.file(), l.line()))
265 .unwrap_or_else(|| "unknown location".to_string());
266
267 log::error!("PANIC at {location}: {}", redact(&payload));
268 log::error!("backtrace:\n{}", std::backtrace::Backtrace::force_capture());
269
270 previous(info);
271 }));
272}
273
274fn panic_payload_string(info: &std::panic::PanicHookInfo<'_>) -> String {
276 let payload = info.payload();
277 if let Some(s) = payload.downcast_ref::<&str>() {
278 (*s).to_string()
279 } else if let Some(s) = payload.downcast_ref::<String>() {
280 s.clone()
281 } else {
282 "non-string panic payload".to_string()
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn redacts_api_key_query_parameter() {
292 let line = "GET https://media.example.com/Items?api_key=abc123def&Limit=50";
293 let out = redact(line);
294 assert!(!out.contains("abc123def"), "token survived: {out}");
295 assert!(out.contains("api_key=[REDACTED]"));
296 assert!(out.contains("media.example.com"));
298 assert!(out.contains("Limit=50"));
299 }
300
301 #[test]
302 fn redacts_every_spelling_of_the_key_parameter() {
303 for key in ["api_key", "ApiKey", "X-Emby-Token", "AccessToken"] {
304 let line = format!("https://h/Items?{key}=SECRETVALUE&x=1");
305 let out = redact(&line);
306 assert!(!out.contains("SECRETVALUE"), "{key} survived: {out}");
307 assert!(out.contains("x=1"), "{key} ate the next parameter: {out}");
308 }
309 }
310
311 #[test]
312 fn redacts_auth_headers() {
313 let out = redact("request headers: X-Emby-Token: abc123, Accept: application/json");
314 assert!(!out.contains("abc123"), "{out}");
315 assert!(out.contains("Accept: application/json"), "{out}");
317 }
318
319 #[test]
320 fn redacts_authorization_header() {
321 let out = redact("Authorization: Bearer verysecrettoken");
322 assert!(!out.contains("verysecrettoken"), "{out}");
323 }
324
325 #[test]
326 fn redacts_json_access_token() {
327 let out = redact(r#"login response {"User":{"Name":"duncan"},"AccessToken":"abc123"}"#);
328 assert!(!out.contains("abc123"), "{out}");
329 assert!(out.contains("duncan"), "{out}");
331 }
332
333 #[test]
334 fn redacts_the_emby_auth_header_form() {
335 let line = r#"MediaBrowser Client="JellyTau", Token="abc123xyz""#;
336 let out = redact(line);
337 assert!(!out.contains("abc123xyz"), "{out}");
338 assert!(out.contains("JellyTau"), "{out}");
339 }
340
341 #[test]
342 fn is_idempotent() {
343 let once = redact("https://h/Items?api_key=abc&z=1");
346 let twice = redact(&once);
347 assert_eq!(once, twice);
348 }
349
350 #[test]
351 fn leaves_ordinary_lines_untouched() {
352 let line = "player: advancing to next episode (item 4f2a, position 0)";
353 assert_eq!(redact(line), line);
354 }
355
356 #[test]
357 fn does_not_redact_the_word_token_in_prose() {
358 let line = "refreshing the access token because the session expired";
361 assert_eq!(redact(line), line);
362 }
363
364 #[test]
365 fn handles_multibyte_characters_without_panicking() {
366 let line = "playing “Où est le café” from https://h/Items?api_key=abc";
368 let out = redact(line);
369 assert!(!out.contains("abc"), "{out}");
370 assert!(out.contains("café"), "{out}");
371 }
372
373 #[test]
374 fn server_url_keeps_scheme_and_host_only() {
375 assert_eq!(
376 redact_server_url("https://media.example.com/jellyfin?api_key=abc"),
377 "https://media.example.com"
378 );
379 assert_eq!(
380 redact_server_url("http://192.168.1.10:8096/"),
381 "http://192.168.1.10:8096"
382 );
383 }
384
385 #[test]
386 fn server_url_drops_embedded_credentials() {
387 assert_eq!(
390 redact_server_url("https://duncan:hunter2@media.example.com/"),
391 "https://media.example.com"
392 );
393 }
394
395 #[test]
396 fn server_url_without_a_scheme_is_refused_rather_than_guessed() {
397 assert_eq!(redact_server_url("media.example.com"), REDACTED);
398 }
399}