Skip to main content

qubit_http/sanitize/
log_sanitizer.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10
11use 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/// Applies a [`LogSanitizePolicy`] to URLs, headers, and body previews.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LogSanitizer {
44    /// Masking and redaction policy.
45    policy: LogSanitizePolicy,
46}
47
48impl LogSanitizer {
49    /// Creates a sanitizer from an explicit policy.
50    ///
51    /// # Parameters
52    /// - `policy`: Sanitization rules.
53    ///
54    /// # Returns
55    /// New [`LogSanitizer`].
56    pub fn new(policy: LogSanitizePolicy) -> Self {
57        Self { policy }
58    }
59
60    /// Creates a debug sanitizer that keeps built-in sensitive names active.
61    ///
62    /// # Parameters
63    /// - `policy`: User-visible policy whose custom names should also apply.
64    ///
65    /// # Returns
66    /// Sanitizer that always includes safe built-in defaults plus custom names.
67    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    /// Returns the underlying policy.
82    ///
83    /// # Returns
84    /// Borrowed policy.
85    pub fn policy(&self) -> &LogSanitizePolicy {
86        &self.policy
87    }
88
89    /// Returns a log-safe URL string with sensitive URL components masked.
90    ///
91    /// # Parameters
92    /// - `url`: URL to render.
93    ///
94    /// # Returns
95    /// Sanitized URL string.
96    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    /// Returns a log-safe header value.
124    ///
125    /// # Parameters
126    /// - `name`: Header name.
127    /// - `value`: Header value.
128    ///
129    /// # Returns
130    /// Masked value for sensitive headers, original value for non-sensitive
131    /// UTF-8 values, or `<non-utf8>` when header value is not valid UTF-8.
132    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    /// Returns log-safe headers for structured debug output.
144    ///
145    /// # Parameters
146    /// - `headers`: Header map to render.
147    ///
148    /// # Returns
149    /// Deterministic map of lowercase header names to sanitized values.
150    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    /// Sanitizes URL-looking tokens inside a diagnostic message.
162    ///
163    /// # Parameters
164    /// - `text`: Message that may contain one or more absolute URLs.
165    ///
166    /// # Returns
167    /// Message with parseable URLs sanitized.
168    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    /// Returns a log-safe preview string for body bytes.
176    ///
177    /// # Parameters
178    /// - `preview`: Bounded body bytes and content metadata.
179    ///
180    /// # Returns
181    /// Sanitized preview with context-appropriate truncation marker.
182    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    /// Returns whether `preview` should be parsed as JSON.
216    ///
217    /// # Parameters
218    /// - `preview`: Preview metadata.
219    /// - `bytes`: Prefix bytes.
220    ///
221    /// # Returns
222    /// `true` when the content type declares JSON or the bytes look like JSON.
223    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    /// Returns whether `preview` should be parsed as newline-delimited JSON.
232    ///
233    /// # Parameters
234    /// - `preview`: Preview metadata.
235    ///
236    /// # Returns
237    /// `true` when the content type declares NDJSON.
238    fn is_ndjson_preview(&self, preview: &BodyPreview<'_>) -> bool {
239        preview.content_type.is_some_and(content_type::is_ndjson)
240    }
241
242    /// Returns whether `preview` should be parsed as form URL encoded data.
243    ///
244    /// # Parameters
245    /// - `preview`: Preview metadata.
246    ///
247    /// # Returns
248    /// `true` when the content type declares a URL-encoded form.
249    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    /// Returns whether `preview` should be treated as multipart data.
256    ///
257    /// # Parameters
258    /// - `preview`: Preview metadata.
259    ///
260    /// # Returns
261    /// `true` when the content type declares any multipart media type.
262    fn is_multipart_preview(&self, preview: &BodyPreview<'_>) -> bool {
263        preview.content_type.is_some_and(content_type::is_multipart)
264    }
265
266    /// Redacts sensitive JSON object keys.
267    ///
268    /// # Parameters
269    /// - `bytes`: UTF-8 JSON bytes.
270    ///
271    /// # Returns
272    /// Sanitized compact JSON text, or `None` when parsing/rendering fails.
273    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    /// Redacts sensitive keys in newline-delimited JSON.
280    ///
281    /// # Parameters
282    /// - `bytes`: UTF-8 NDJSON bytes.
283    ///
284    /// # Returns
285    /// Sanitized NDJSON text, or `None` when any non-empty line fails to parse.
286    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    /// Redacts sensitive keys recursively in one JSON value.
307    ///
308    /// # Parameters
309    /// - `value`: JSON value to mutate in place.
310    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    /// Redacts sensitive URL-encoded form fields.
331    ///
332    /// # Parameters
333    /// - `bytes`: Form URL encoded bytes.
334    ///
335    /// # Returns
336    /// Sanitized form body text.
337    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    /// Redacts sensitive fields in one complete multipart body.
350    ///
351    /// # Parameters
352    /// - `preview`: Preview metadata, including content type and truncation state.
353    /// - `bytes`: Complete body bytes to parse.
354    ///
355    /// # Returns
356    /// Sanitized multipart summary, or `None` when the body must be fully redacted.
357    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    /// Redacts one multipart part and renders a log summary line.
376    ///
377    /// # Parameters
378    /// - `segment`: Raw part segment without boundary delimiter lines.
379    ///
380    /// # Returns
381    /// Log-safe `name=value` summary line, or `None` for malformed headers.
382    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    /// Redacts or renders one multipart part value.
408    ///
409    /// # Parameters
410    /// - `field_name`: Parsed multipart field name.
411    /// - `filename`: Optional file name from content disposition.
412    /// - `content_type`: Optional part-level content type.
413    /// - `body`: Part body text.
414    ///
415    /// # Returns
416    /// Log-safe value for the part.
417    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    /// Sanitizes a single whitespace-delimited diagnostic token.
456    ///
457    /// # Parameters
458    /// - `token`: One token from a diagnostic message.
459    ///
460    /// # Returns
461    /// Token with embedded URL credentials and query secrets masked.
462    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    /// Creates a sanitizer using [`LogSanitizePolicy::default`].
487    fn default() -> Self {
488        Self::new(LogSanitizePolicy::default())
489    }
490}
491
492/// Masks one sensitive string using the crate's edge-preserving convention.
493///
494/// # Parameters
495/// - `value`: Sensitive value.
496///
497/// # Returns
498/// Masked value.
499fn 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
511/// Finds the first absolute HTTP URL scheme inside `token`.
512///
513/// # Parameters
514/// - `token`: Diagnostic token to inspect.
515///
516/// # Returns
517/// Byte offset where the scheme starts, or `None`.
518fn 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
527/// Returns the previous UTF-8 character boundary and character.
528///
529/// # Parameters
530/// - `text`: Source text.
531/// - `end`: Current byte end offset.
532///
533/// # Returns
534/// Previous byte offset and character, or `None` at the start.
535fn previous_char_boundary(text: &str, end: usize) -> Option<(usize, char)> {
536    text[..end].char_indices().next_back()
537}
538
539/// Returns whether `ch` is punctuation commonly adjacent to a URL in prose.
540///
541/// # Parameters
542/// - `ch`: Candidate trailing character.
543///
544/// # Returns
545/// `true` if the character may be peeled from a failed URL parse attempt.
546fn is_trimmable_url_suffix(ch: char) -> bool {
547    matches!(
548        ch,
549        '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\''
550    )
551}
552
553/// Kind of multipart delimiter line found in a body.
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555enum MultipartDelimiter {
556    /// Delimiter before a regular part.
557    Part,
558    /// Final closing delimiter.
559    Closing,
560}
561
562/// Splits a complete multipart body into raw part segments.
563///
564/// # Parameters
565/// - `text`: Multipart body text.
566/// - `boundary`: Boundary parameter without the leading `--`.
567///
568/// # Returns
569/// Raw part segments without boundary delimiter lines, or `None` for malformed bodies.
570fn 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
599/// Returns the next line range and the following scan position.
600///
601/// # Parameters
602/// - `text`: Source text.
603/// - `position`: Byte offset where the next line starts.
604///
605/// # Returns
606/// `(line_start, line_end_without_line_ending, next_position)`.
607fn 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
619/// Classifies a multipart boundary delimiter line.
620///
621/// # Parameters
622/// - `line`: One logical line without the trailing CRLF/LF.
623/// - `boundary`: Boundary parameter without the leading `--`.
624///
625/// # Returns
626/// Delimiter kind for exact delimiter lines, otherwise `None`.
627fn 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
638/// Splits multipart part headers from the part body.
639///
640/// # Parameters
641/// - `segment`: Raw part segment.
642///
643/// # Returns
644/// Header text and body text.
645fn 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
655/// Removes one trailing multipart line ending.
656///
657/// # Parameters
658/// - `value`: Text that may end with a line ending.
659///
660/// # Returns
661/// Text without one trailing line ending.
662fn 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
669/// Trims ASCII whitespace from both ends of `bytes`.
670///
671/// # Parameters
672/// - `bytes`: Bytes to trim.
673///
674/// # Returns
675/// Borrowed trimmed slice.
676fn 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}