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 http::{
12    HeaderMap,
13    HeaderName,
14    HeaderValue,
15};
16use qubit_sanitize::{
17    FieldSanitizePolicy,
18    FieldSanitizer,
19    HttpBodySanitizer,
20    HttpHeaderSanitizer,
21    MaskPolicies,
22    NameMatchMode,
23    SensitiveFields,
24    UrlSanitizer,
25};
26use url::Url;
27
28use super::{
29    BodyLogContext,
30    BodyPreview,
31    LogSanitizePolicy,
32};
33
34const INVALID_CONTENT_TYPE_BODY_REDACTED: &str = "<redacted: invalid content type body>";
35const LOG_NAME_MATCH_MODE: NameMatchMode = NameMatchMode::ExactOrSuffix;
36
37/// Applies a [`LogSanitizePolicy`] to URLs, headers, and body previews.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct LogSanitizer {
40    /// Masking and redaction policy.
41    policy: LogSanitizePolicy,
42    /// URL sanitizer from `qubit-sanitize`.
43    url_sanitizer: UrlSanitizer,
44    /// Header sanitizer from `qubit-sanitize`.
45    header_sanitizer: HttpHeaderSanitizer,
46    /// Body sanitizer from `qubit-sanitize`.
47    body_sanitizer: HttpBodySanitizer,
48}
49
50impl LogSanitizer {
51    /// Creates a sanitizer from an explicit policy.
52    ///
53    /// # Parameters
54    /// - `policy`: Sanitization rules.
55    ///
56    /// # Returns
57    /// New [`LogSanitizer`].
58    pub fn new(policy: LogSanitizePolicy) -> Self {
59        Self {
60            url_sanitizer: UrlSanitizer::new(field_sanitizer(&policy.sensitive_query_params)),
61            header_sanitizer: HttpHeaderSanitizer::new(field_sanitizer(&policy.sensitive_headers)),
62            body_sanitizer: HttpBodySanitizer::new(field_sanitizer(&policy.sensitive_body_fields)),
63            policy,
64        }
65    }
66
67    /// Creates a debug sanitizer that keeps built-in sensitive names active.
68    ///
69    /// # Parameters
70    /// - `policy`: User-visible policy whose custom names should also apply.
71    ///
72    /// # Returns
73    /// Sanitizer that always includes safe built-in defaults plus custom names.
74    pub(crate) fn for_debug(policy: &LogSanitizePolicy) -> Self {
75        let mut debug_policy = LogSanitizePolicy::default();
76        extend_sensitive_fields(&mut debug_policy.sensitive_headers, &policy.sensitive_headers);
77        extend_sensitive_fields(&mut debug_policy.sensitive_query_params, &policy.sensitive_query_params);
78        extend_sensitive_fields(&mut debug_policy.sensitive_body_fields, &policy.sensitive_body_fields);
79        Self::new(debug_policy)
80    }
81
82    /// Returns the underlying policy.
83    ///
84    /// # Returns
85    /// Borrowed policy.
86    pub fn policy(&self) -> &LogSanitizePolicy {
87        &self.policy
88    }
89
90    /// Returns a log-safe URL string with sensitive URL components masked.
91    ///
92    /// # Parameters
93    /// - `url`: URL to render.
94    ///
95    /// # Returns
96    /// Sanitized URL string.
97    pub fn sanitize_url(&self, url: &Url) -> String {
98        self.url_sanitizer.sanitize_url(url, LOG_NAME_MATCH_MODE)
99    }
100
101    /// Returns a log-safe header value.
102    ///
103    /// # Parameters
104    /// - `name`: Header name.
105    /// - `value`: Header value.
106    ///
107    /// # Returns
108    /// Masked value for sensitive headers, original value for non-sensitive
109    /// UTF-8 values, or `<non-utf8>` when header value is not valid UTF-8.
110    pub fn sanitize_header_value(&self, name: &HeaderName, value: &HeaderValue) -> String {
111        self.header_sanitizer.sanitize_value(name, value, LOG_NAME_MATCH_MODE)
112    }
113
114    /// Returns log-safe headers for structured debug output.
115    ///
116    /// # Parameters
117    /// - `headers`: Header map to render.
118    ///
119    /// # Returns
120    /// Deterministic map of lowercase header names to sanitized values.
121    pub(crate) fn sanitize_header_map(&self, headers: &HeaderMap) -> std::collections::BTreeMap<String, Vec<String>> {
122        self.header_sanitizer.sanitize_headers(headers, LOG_NAME_MATCH_MODE)
123    }
124
125    /// Returns a log-safe request body preview.
126    ///
127    /// # Parameters
128    /// - `body`: Source request body bytes.
129    /// - `limit`: Maximum preview bytes; values below 1 are clamped to 1.
130    /// - `content_type`: Optional Content-Type header used for structured redaction.
131    ///
132    /// # Returns
133    /// Sanitized request body preview with request-style truncation suffix.
134    pub fn sanitize_request_body_preview(&self, body: &[u8], limit: usize, content_type: Option<&str>) -> String {
135        self.sanitize_body_bytes(body, limit, BodyLogContext::Request, content_type)
136    }
137
138    /// Returns a log-safe response body preview.
139    ///
140    /// # Parameters
141    /// - `body`: Source response body bytes.
142    /// - `limit`: Maximum preview bytes; values below 1 are clamped to 1.
143    /// - `content_type`: Optional Content-Type header used for structured redaction.
144    ///
145    /// # Returns
146    /// Sanitized response body preview with response-style truncation suffix.
147    pub fn sanitize_response_body_preview(&self, body: &[u8], limit: usize, content_type: Option<&str>) -> String {
148        self.sanitize_body_bytes(body, limit, BodyLogContext::Response, content_type)
149    }
150
151    /// Returns a log-safe status-error body preview.
152    ///
153    /// # Parameters
154    /// - `body`: Source non-success response body bytes.
155    /// - `limit`: Maximum preview bytes; values below 1 are clamped to 1.
156    /// - `content_type`: Optional Content-Type header used for structured redaction.
157    ///
158    /// # Returns
159    /// Sanitized error body preview with status-error truncation suffix.
160    pub fn sanitize_error_response_body_preview(
161        &self,
162        body: &[u8],
163        limit: usize,
164        content_type: Option<&str>,
165    ) -> String {
166        self.sanitize_body_bytes(body, limit, BodyLogContext::ErrorResponse, content_type)
167    }
168
169    /// Sanitizes URL-looking tokens inside a diagnostic message.
170    ///
171    /// # Parameters
172    /// - `text`: Message that may contain one or more absolute URLs.
173    ///
174    /// # Returns
175    /// Message with parseable URLs sanitized.
176    pub(crate) fn sanitize_diagnostic_text(&self, text: &str) -> String {
177        let mut sanitized = String::with_capacity(text.len());
178        let mut token_start = None;
179        for (index, ch) in text.char_indices() {
180            if ch.is_whitespace() {
181                if let Some(start) = token_start.take() {
182                    sanitized.push_str(&self.sanitize_diagnostic_token(&text[start..index]));
183                }
184                sanitized.push(ch);
185            } else if token_start.is_none() {
186                token_start = Some(index);
187            }
188        }
189        if let Some(start) = token_start {
190            sanitized.push_str(&self.sanitize_diagnostic_token(&text[start..]));
191        }
192        sanitized
193    }
194
195    /// Returns a log-safe preview string for body bytes.
196    ///
197    /// # Parameters
198    /// - `preview`: Bounded body bytes and content metadata.
199    ///
200    /// # Returns
201    /// Sanitized preview with context-appropriate truncation marker.
202    pub(crate) fn sanitize_body_preview(&self, preview: &BodyPreview<'_>) -> String {
203        let content_type = match preview.content_type {
204            Some(content_type) => match HeaderValue::from_str(content_type) {
205                Ok(content_type) => Some(content_type),
206                Err(_) => return Self::invalid_content_type_body(preview),
207            },
208            None => None,
209        };
210        let rendered = self.body_sanitizer.sanitize_body_preview(
211            preview.prefix(),
212            preview.source_len(),
213            content_type.as_ref(),
214            LOG_NAME_MATCH_MODE,
215        );
216        Self::normalize_error_truncation_suffix(rendered, preview)
217    }
218
219    /// Sanitizes body bytes for one logging call site.
220    ///
221    /// # Parameters
222    /// - `body`: Source body bytes.
223    /// - `limit`: Maximum preview bytes.
224    /// - `context`: Logging call site that controls truncation wording.
225    /// - `content_type`: Optional Content-Type header used for structured redaction.
226    ///
227    /// # Returns
228    /// Sanitized body preview text.
229    fn sanitize_body_bytes(
230        &self,
231        body: &[u8],
232        limit: usize,
233        context: BodyLogContext,
234        content_type: Option<&str>,
235    ) -> String {
236        let preview = BodyPreview::new(body, limit, context);
237        let preview = if let Some(content_type) = content_type {
238            preview.with_content_type(content_type)
239        } else {
240            preview
241        };
242        self.sanitize_body_preview(&preview)
243    }
244
245    /// Sanitizes a single whitespace-delimited diagnostic token.
246    ///
247    /// # Parameters
248    /// - `token`: One token from a diagnostic message.
249    ///
250    /// # Returns
251    /// Token with embedded URL credentials and query secrets masked.
252    fn sanitize_diagnostic_token(&self, token: &str) -> String {
253        let Some(scheme_start) = find_url_scheme_start(token) else {
254            return token.to_string();
255        };
256        let prefix = &token[..scheme_start];
257        let mut candidate_end = token.len();
258        loop {
259            let candidate = &token[scheme_start..candidate_end];
260            if let Ok(url) = Url::parse(candidate) {
261                let suffix = &token[candidate_end..];
262                return format!("{prefix}{}{suffix}", self.sanitize_url(&url));
263            }
264            let (previous, ch) =
265                previous_char_boundary(token, candidate_end).expect("candidate end is always after URL scheme start");
266            if previous <= scheme_start || !is_trimmable_url_suffix(ch) {
267                return token.to_string();
268            }
269            candidate_end = previous;
270        }
271    }
272
273    /// Renders an invalid content-type body redaction marker.
274    ///
275    /// # Parameters
276    /// - `preview`: Preview metadata.
277    ///
278    /// # Returns
279    /// Redaction marker with the rs-http truncation suffix.
280    fn invalid_content_type_body(preview: &BodyPreview<'_>) -> String {
281        format!("{INVALID_CONTENT_TYPE_BODY_REDACTED}{}", preview.truncation_suffix())
282    }
283
284    /// Converts `qubit-sanitize` counted truncation suffix to rs-http's
285    /// historical status-error suffix.
286    ///
287    /// # Parameters
288    /// - `rendered`: Body text returned by `qubit-sanitize`.
289    /// - `preview`: Original preview metadata.
290    ///
291    /// # Returns
292    /// Body text with the suffix expected by status-error diagnostics.
293    fn normalize_error_truncation_suffix(rendered: String, preview: &BodyPreview<'_>) -> String {
294        if preview.context != BodyLogContext::ErrorResponse || !preview.is_truncated() {
295            return rendered;
296        }
297        let counted = format!(
298            "...<truncated {} bytes>",
299            preview.source_len().saturating_sub(preview.prefix().len())
300        );
301        if let Some(prefix) = rendered.strip_suffix(&counted) {
302            format!("{prefix}{}", preview.truncation_suffix())
303        } else {
304            format!("{rendered}{}", preview.truncation_suffix())
305        }
306    }
307}
308
309fn field_sanitizer(fields: &SensitiveFields) -> FieldSanitizer {
310    FieldSanitizer::new(FieldSanitizePolicy {
311        sensitive_fields: fields.clone(),
312        mask_policies: MaskPolicies::default(),
313    })
314}
315
316fn extend_sensitive_fields(target: &mut SensitiveFields, source: &SensitiveFields) {
317    for (field, level) in source.iter() {
318        target.insert(field, level);
319    }
320}
321
322impl Default for LogSanitizer {
323    /// Creates a sanitizer using [`LogSanitizePolicy::default`].
324    fn default() -> Self {
325        Self::new(LogSanitizePolicy::default())
326    }
327}
328
329/// Finds the first absolute HTTP URL scheme inside `token`.
330///
331/// # Parameters
332/// - `token`: Diagnostic token to inspect.
333///
334/// # Returns
335/// Byte offset where the scheme starts, or `None`.
336fn find_url_scheme_start(token: &str) -> Option<usize> {
337    match (
338        find_ascii_case_insensitive(token, "http://"),
339        find_ascii_case_insensitive(token, "https://"),
340    ) {
341        (Some(http), Some(https)) => Some(http.min(https)),
342        (Some(http), None) => Some(http),
343        (None, Some(https)) => Some(https),
344        (None, None) => None,
345    }
346}
347
348/// Finds an ASCII needle inside `text` without requiring matching case.
349///
350/// # Parameters
351/// - `text`: Text to scan.
352/// - `needle`: ASCII substring to find.
353///
354/// # Returns
355/// Byte offset of the first match, or `None`.
356fn find_ascii_case_insensitive(text: &str, needle: &str) -> Option<usize> {
357    let needle = needle.as_bytes();
358    if needle.is_empty() || text.len() < needle.len() {
359        return None;
360    }
361    text.as_bytes()
362        .windows(needle.len())
363        .position(|window| window.eq_ignore_ascii_case(needle))
364}
365
366/// Returns the previous UTF-8 character boundary and character.
367///
368/// # Parameters
369/// - `text`: Source text.
370/// - `end`: Current byte end offset.
371///
372/// # Returns
373/// Previous byte offset and character, or `None` at the start.
374fn previous_char_boundary(text: &str, end: usize) -> Option<(usize, char)> {
375    text[..end].char_indices().next_back()
376}
377
378/// Returns whether `ch` is punctuation commonly adjacent to a URL in prose.
379///
380/// # Parameters
381/// - `ch`: Candidate trailing character.
382///
383/// # Returns
384/// `true` if the character may be peeled from a failed URL parse attempt.
385fn is_trimmable_url_suffix(ch: char) -> bool {
386    matches!(ch, '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\'')
387}