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