1use std::collections::BTreeMap;
12
13use http::{
14 HeaderMap,
15 HeaderName,
16 HeaderValue,
17};
18use serde_json::Value;
19use url::{
20 form_urlencoded,
21 Url,
22};
23
24use crate::constants::{
25 SENSITIVE_HEADER_MASK_EDGE_CHARS,
26 SENSITIVE_HEADER_MASK_PLACEHOLDER,
27 SENSITIVE_HEADER_MASK_SHORT_LEN,
28};
29use crate::content_type;
30
31use super::{
32 BodyPreview,
33 LogSanitizePolicy,
34};
35
36const MULTIPART_BODY_REDACTED: &str = "<redacted: multipart body>";
37const MULTIPART_PART_REDACTED: &str = "<redacted: multipart part>";
38const MULTIPART_FILE_PART_REDACTED: &str = "<redacted: file part>";
39const MULTIPART_UNNAMED_FIELD: &str = "<unnamed>";
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LogSanitizer {
44 policy: LogSanitizePolicy,
46}
47
48impl LogSanitizer {
49 pub fn new(policy: LogSanitizePolicy) -> Self {
57 Self { policy }
58 }
59
60 pub(crate) fn for_debug(policy: &LogSanitizePolicy) -> Self {
68 let mut debug_policy = LogSanitizePolicy::default();
69 debug_policy
70 .sensitive_headers
71 .extend(policy.sensitive_headers.iter());
72 debug_policy
73 .sensitive_query_params
74 .extend(policy.sensitive_query_params.iter());
75 debug_policy
76 .sensitive_body_fields
77 .extend(policy.sensitive_body_fields.iter());
78 Self::new(debug_policy)
79 }
80
81 pub fn policy(&self) -> &LogSanitizePolicy {
86 &self.policy
87 }
88
89 pub fn sanitize_url(&self, url: &Url) -> String {
97 let mut sanitized = url.clone();
98 if !sanitized.username().is_empty() {
99 let _ = sanitized.set_username(SENSITIVE_HEADER_MASK_PLACEHOLDER);
100 }
101 if sanitized.password().is_some() {
102 let _ = sanitized.set_password(Some(SENSITIVE_HEADER_MASK_PLACEHOLDER));
103 }
104 if sanitized.fragment().is_some() {
105 sanitized.set_fragment(Some(SENSITIVE_HEADER_MASK_PLACEHOLDER));
106 }
107 let Some(_) = sanitized.query() else {
108 return sanitized.to_string();
109 };
110
111 let mut serializer = form_urlencoded::Serializer::new(String::new());
112 for (key, value) in url.query_pairs() {
113 if self.policy.sensitive_query_params.contains(key.as_ref()) {
114 serializer.append_pair(key.as_ref(), SENSITIVE_HEADER_MASK_PLACEHOLDER);
115 } else {
116 serializer.append_pair(key.as_ref(), value.as_ref());
117 }
118 }
119 sanitized.set_query(Some(&serializer.finish()));
120 sanitized.to_string()
121 }
122
123 pub fn sanitize_header_value(&self, name: &HeaderName, value: &HeaderValue) -> String {
133 let value = value.to_str().unwrap_or("<non-utf8>");
134 if value.is_empty() {
135 return String::new();
136 }
137 if !self.policy.sensitive_headers.contains(name.as_str()) {
138 return value.to_string();
139 }
140 mask_sensitive_value(value)
141 }
142
143 pub(crate) fn sanitize_header_map(&self, headers: &HeaderMap) -> BTreeMap<String, Vec<String>> {
151 let mut result = BTreeMap::<String, Vec<String>>::new();
152 for (name, value) in headers {
153 result
154 .entry(name.as_str().to_string())
155 .or_default()
156 .push(self.sanitize_header_value(name, value));
157 }
158 result
159 }
160
161 pub(crate) fn sanitize_diagnostic_text(&self, text: &str) -> String {
169 text.split_whitespace()
170 .map(|token| self.sanitize_diagnostic_token(token))
171 .collect::<Vec<_>>()
172 .join(" ")
173 }
174
175 pub fn sanitize_body_preview(&self, preview: &BodyPreview<'_>) -> String {
183 let bytes = preview.prefix();
184 if bytes.is_empty() {
185 return "<empty>".to_string();
186 }
187 let suffix = preview.truncation_suffix();
188 if self.is_multipart_preview(preview) {
189 if let Some(text) = self.sanitize_multipart(preview, bytes) {
190 return format!("{text}{suffix}");
191 }
192 return format!("{MULTIPART_BODY_REDACTED}{suffix}");
193 }
194 if self.is_ndjson_preview(preview) {
195 if let Some(text) = self.sanitize_ndjson(bytes) {
196 return format!("{text}{suffix}");
197 }
198 return format!("<redacted: invalid or truncated NDJSON>{suffix}");
199 }
200 if self.is_json_preview(preview, bytes) {
201 if let Some(text) = self.sanitize_json(bytes) {
202 return format!("{text}{suffix}");
203 }
204 return format!("<redacted: invalid or truncated JSON>{suffix}");
205 }
206 if self.is_form_preview(preview) {
207 return format!("{}{}", self.sanitize_form(bytes), suffix);
208 }
209 match std::str::from_utf8(bytes) {
210 Ok(text) => format!("{text}{suffix}"),
211 Err(_) => format!("<binary {} bytes>{suffix}", preview.source_len()),
212 }
213 }
214
215 fn is_json_preview(&self, preview: &BodyPreview<'_>, bytes: &[u8]) -> bool {
224 if preview.content_type.is_some_and(content_type::is_json) {
225 return true;
226 }
227 let trimmed = trim_ascii_whitespace(bytes);
228 matches!(trimmed.first(), Some(b'{') | Some(b'['))
229 }
230
231 fn is_ndjson_preview(&self, preview: &BodyPreview<'_>) -> bool {
239 preview.content_type.is_some_and(content_type::is_ndjson)
240 }
241
242 fn is_form_preview(&self, preview: &BodyPreview<'_>) -> bool {
250 preview
251 .content_type
252 .is_some_and(content_type::is_form_urlencoded)
253 }
254
255 fn is_multipart_preview(&self, preview: &BodyPreview<'_>) -> bool {
263 preview.content_type.is_some_and(content_type::is_multipart)
264 }
265
266 fn sanitize_json(&self, bytes: &[u8]) -> Option<String> {
274 let mut value = serde_json::from_slice::<Value>(bytes).ok()?;
275 self.redact_json_value(&mut value);
276 serde_json::to_string(&value).ok()
277 }
278
279 fn sanitize_ndjson(&self, bytes: &[u8]) -> Option<String> {
287 let text = std::str::from_utf8(bytes).ok()?;
288 let trailing_newline = text.ends_with('\n');
289 let mut sanitized_lines = Vec::new();
290 for line in text.lines() {
291 if line.trim().is_empty() {
292 sanitized_lines.push(String::new());
293 continue;
294 }
295 let mut value = serde_json::from_str::<Value>(line).ok()?;
296 self.redact_json_value(&mut value);
297 sanitized_lines.push(serde_json::to_string(&value).ok()?);
298 }
299 let mut result = sanitized_lines.join("\n");
300 if trailing_newline {
301 result.push('\n');
302 }
303 Some(result)
304 }
305
306 fn redact_json_value(&self, value: &mut Value) {
311 match value {
312 Value::Object(map) => {
313 for (key, value) in map.iter_mut() {
314 if self.policy.sensitive_body_fields.contains(key) {
315 *value = Value::String(SENSITIVE_HEADER_MASK_PLACEHOLDER.to_string());
316 } else {
317 self.redact_json_value(value);
318 }
319 }
320 }
321 Value::Array(items) => {
322 for item in items {
323 self.redact_json_value(item);
324 }
325 }
326 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
327 }
328 }
329
330 fn sanitize_form(&self, bytes: &[u8]) -> String {
338 let mut serializer = form_urlencoded::Serializer::new(String::new());
339 for (key, value) in form_urlencoded::parse(bytes) {
340 if self.policy.sensitive_body_fields.contains(key.as_ref()) {
341 serializer.append_pair(key.as_ref(), SENSITIVE_HEADER_MASK_PLACEHOLDER);
342 } else {
343 serializer.append_pair(key.as_ref(), value.as_ref());
344 }
345 }
346 serializer.finish()
347 }
348
349 fn sanitize_multipart(&self, preview: &BodyPreview<'_>, bytes: &[u8]) -> Option<String> {
358 if preview.is_truncated() {
359 return None;
360 }
361 let content_type = preview.content_type?;
362 let boundary = content_type::multipart_boundary(content_type)?;
363 let text = std::str::from_utf8(bytes).ok()?;
364 let segments = multipart_part_segments(text, &boundary)?;
365 let mut lines = Vec::with_capacity(segments.len());
366 for segment in segments {
367 lines.push(self.sanitize_multipart_part(segment)?);
368 }
369 if lines.is_empty() {
370 return Some("<multipart>\n</multipart>".to_string());
371 }
372 Some(format!("<multipart>\n{}\n</multipart>", lines.join("\n")))
373 }
374
375 fn sanitize_multipart_part(&self, segment: &str) -> Option<String> {
383 let (headers, body) = split_multipart_headers_and_body(segment)?;
384 let mut content_disposition = None;
385 let mut content_type = None;
386 for line in headers.lines().filter(|line| !line.trim().is_empty()) {
387 let (header_name, header_value) = line.split_once(':')?;
388 let header_name = header_name.trim();
389 let header_value = header_value.trim();
390 if header_name.eq_ignore_ascii_case("content-disposition") {
391 content_disposition = Some(header_value);
392 } else if header_name.eq_ignore_ascii_case("content-type") {
393 content_type = Some(header_value);
394 }
395 }
396 let name = content_disposition.and_then(|value| content_type::parameter(value, "name"));
397 let filename = content_disposition.and_then(|value| {
398 content_type::parameter(value, "filename")
399 .or_else(|| content_type::parameter(value, "filename*"))
400 });
401 let field_name = name.as_deref().unwrap_or(MULTIPART_UNNAMED_FIELD);
402 let value =
403 self.sanitize_multipart_part_value(field_name, filename.as_deref(), content_type, body);
404 Some(format!("{field_name}={value}"))
405 }
406
407 fn sanitize_multipart_part_value(
418 &self,
419 field_name: &str,
420 filename: Option<&str>,
421 content_type: Option<&str>,
422 body: &str,
423 ) -> String {
424 if self.policy.sensitive_body_fields.contains(field_name) {
425 return SENSITIVE_HEADER_MASK_PLACEHOLDER.to_string();
426 }
427 if filename.is_some() {
428 return MULTIPART_FILE_PART_REDACTED.to_string();
429 }
430 if field_name == MULTIPART_UNNAMED_FIELD {
431 return MULTIPART_PART_REDACTED.to_string();
432 }
433 let Some(content_type) = content_type else {
434 return body.to_string();
435 };
436 if content_type::is_json(content_type) {
437 return self
438 .sanitize_json(body.as_bytes())
439 .unwrap_or_else(|| MULTIPART_PART_REDACTED.to_string());
440 }
441 if content_type::is_ndjson(content_type) {
442 return self
443 .sanitize_ndjson(body.as_bytes())
444 .unwrap_or_else(|| MULTIPART_PART_REDACTED.to_string());
445 }
446 if content_type::is_form_urlencoded(content_type) {
447 return self.sanitize_form(body.as_bytes());
448 }
449 if content_type::is_text(content_type) {
450 return body.to_string();
451 }
452 MULTIPART_PART_REDACTED.to_string()
453 }
454
455 fn sanitize_diagnostic_token(&self, token: &str) -> String {
463 let Some(scheme_start) = find_url_scheme_start(token) else {
464 return token.to_string();
465 };
466 let prefix = &token[..scheme_start];
467 let mut candidate_end = token.len();
468 loop {
469 let candidate = &token[scheme_start..candidate_end];
470 if let Ok(url) = Url::parse(candidate) {
471 let suffix = &token[candidate_end..];
472 return format!("{prefix}{}{suffix}", self.sanitize_url(&url));
473 }
474 let Some((previous, ch)) = previous_char_boundary(token, candidate_end) else {
475 return token.to_string();
476 };
477 if previous <= scheme_start || !is_trimmable_url_suffix(ch) {
478 return token.to_string();
479 }
480 candidate_end = previous;
481 }
482 }
483}
484
485impl Default for LogSanitizer {
486 fn default() -> Self {
488 Self::new(LogSanitizePolicy::default())
489 }
490}
491
492fn mask_sensitive_value(value: &str) -> String {
500 let chars: Vec<char> = value.chars().collect();
501 if chars.len() <= SENSITIVE_HEADER_MASK_SHORT_LEN {
502 SENSITIVE_HEADER_MASK_PLACEHOLDER.to_string()
503 } else {
504 let edge = SENSITIVE_HEADER_MASK_EDGE_CHARS;
505 let prefix: String = chars[..edge].iter().collect();
506 let suffix: String = chars[chars.len() - edge..].iter().collect();
507 format!("{prefix}{SENSITIVE_HEADER_MASK_PLACEHOLDER}{suffix}")
508 }
509}
510
511fn find_url_scheme_start(token: &str) -> Option<usize> {
519 match (token.find("http://"), token.find("https://")) {
520 (Some(http), Some(https)) => Some(http.min(https)),
521 (Some(http), None) => Some(http),
522 (None, Some(https)) => Some(https),
523 (None, None) => None,
524 }
525}
526
527fn previous_char_boundary(text: &str, end: usize) -> Option<(usize, char)> {
536 text[..end].char_indices().next_back()
537}
538
539fn is_trimmable_url_suffix(ch: char) -> bool {
547 matches!(
548 ch,
549 '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\''
550 )
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555enum MultipartDelimiter {
556 Part,
558 Closing,
560}
561
562fn multipart_part_segments<'a>(text: &'a str, boundary: &str) -> Option<Vec<&'a str>> {
571 let mut current_start = None;
572 let mut segments = Vec::new();
573 let mut position = 0;
574 while position < text.len() {
575 let (line_start, line_end, next_position) = next_line_bounds(text, position);
576 let line = &text[line_start..line_end];
577 let Some(delimiter) = multipart_delimiter(line, boundary) else {
578 position = next_position;
579 continue;
580 };
581 if let Some(start) = current_start {
582 let segment = strip_one_trailing_line_ending(&text[start..line_start]);
583 if !segment.trim().is_empty() {
584 segments.push(segment);
585 }
586 }
587 if delimiter == MultipartDelimiter::Closing {
588 if text[next_position..].trim().is_empty() {
589 return Some(segments);
590 }
591 return None;
592 }
593 current_start = Some(next_position);
594 position = next_position;
595 }
596 None
597}
598
599fn next_line_bounds(text: &str, position: usize) -> (usize, usize, usize) {
608 if let Some(relative_end) = text[position..].find('\n') {
609 let line_end = position + relative_end;
610 let trimmed_end = line_end
611 .checked_sub(1)
612 .filter(|index| text.as_bytes()[*index] == b'\r')
613 .unwrap_or(line_end);
614 return (position, trimmed_end, line_end + 1);
615 }
616 (position, text.len(), text.len())
617}
618
619fn multipart_delimiter(line: &str, boundary: &str) -> Option<MultipartDelimiter> {
628 let delimiter = format!("--{boundary}");
629 if line == delimiter {
630 Some(MultipartDelimiter::Part)
631 } else if line == format!("{delimiter}--") {
632 Some(MultipartDelimiter::Closing)
633 } else {
634 None
635 }
636}
637
638fn split_multipart_headers_and_body(segment: &str) -> Option<(&str, &str)> {
646 if let Some(index) = segment.find("\r\n\r\n") {
647 return Some((&segment[..index], &segment[index + 4..]));
648 }
649 if let Some(index) = segment.find("\n\n") {
650 return Some((&segment[..index], &segment[index + 2..]));
651 }
652 None
653}
654
655fn strip_one_trailing_line_ending(value: &str) -> &str {
663 value
664 .strip_suffix("\r\n")
665 .or_else(|| value.strip_suffix('\n'))
666 .unwrap_or(value)
667}
668
669fn trim_ascii_whitespace(bytes: &[u8]) -> &[u8] {
677 let start = bytes
678 .iter()
679 .position(|byte| !byte.is_ascii_whitespace())
680 .unwrap_or(bytes.len());
681 let end = bytes
682 .iter()
683 .rposition(|byte| !byte.is_ascii_whitespace())
684 .map(|index| index + 1)
685 .unwrap_or(start);
686 &bytes[start..end]
687}