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}