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}