use http::{
HeaderMap,
HeaderName,
HeaderValue,
};
use qubit_sanitize::{
FieldSanitizePolicy,
FieldSanitizer,
HttpBodySanitizer,
HttpHeaderSanitizer,
MaskPolicies,
NameMatchMode,
SensitiveFields,
UrlSanitizer,
};
use url::Url;
use super::{
BodyLogContext,
BodyPreview,
LogSanitizePolicy,
};
const INVALID_CONTENT_TYPE_BODY_REDACTED: &str = "<redacted: invalid content type body>";
const LOG_NAME_MATCH_MODE: NameMatchMode = NameMatchMode::ExactOrSuffix;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogSanitizer {
policy: LogSanitizePolicy,
url_sanitizer: UrlSanitizer,
header_sanitizer: HttpHeaderSanitizer,
body_sanitizer: HttpBodySanitizer,
}
impl LogSanitizer {
pub fn new(policy: LogSanitizePolicy) -> Self {
Self {
url_sanitizer: UrlSanitizer::new(field_sanitizer(&policy.sensitive_query_params)),
header_sanitizer: HttpHeaderSanitizer::new(field_sanitizer(&policy.sensitive_headers)),
body_sanitizer: HttpBodySanitizer::new(field_sanitizer(&policy.sensitive_body_fields)),
policy,
}
}
pub(crate) fn for_debug(policy: &LogSanitizePolicy) -> Self {
let mut debug_policy = LogSanitizePolicy::default();
extend_sensitive_fields(&mut debug_policy.sensitive_headers, &policy.sensitive_headers);
extend_sensitive_fields(&mut debug_policy.sensitive_query_params, &policy.sensitive_query_params);
extend_sensitive_fields(&mut debug_policy.sensitive_body_fields, &policy.sensitive_body_fields);
Self::new(debug_policy)
}
pub fn policy(&self) -> &LogSanitizePolicy {
&self.policy
}
pub fn sanitize_url(&self, url: &Url) -> String {
self.url_sanitizer.sanitize_url(url, LOG_NAME_MATCH_MODE)
}
pub fn sanitize_header_value(&self, name: &HeaderName, value: &HeaderValue) -> String {
self.header_sanitizer.sanitize_value(name, value, LOG_NAME_MATCH_MODE)
}
pub(crate) fn sanitize_header_map(&self, headers: &HeaderMap) -> std::collections::BTreeMap<String, Vec<String>> {
self.header_sanitizer.sanitize_headers(headers, LOG_NAME_MATCH_MODE)
}
pub fn sanitize_request_body_preview(&self, body: &[u8], limit: usize, content_type: Option<&str>) -> String {
self.sanitize_body_bytes(body, limit, BodyLogContext::Request, content_type)
}
pub fn sanitize_response_body_preview(&self, body: &[u8], limit: usize, content_type: Option<&str>) -> String {
self.sanitize_body_bytes(body, limit, BodyLogContext::Response, content_type)
}
pub fn sanitize_error_response_body_preview(
&self,
body: &[u8],
limit: usize,
content_type: Option<&str>,
) -> String {
self.sanitize_body_bytes(body, limit, BodyLogContext::ErrorResponse, content_type)
}
pub(crate) fn sanitize_diagnostic_text(&self, text: &str) -> String {
let mut sanitized = String::with_capacity(text.len());
let mut token_start = None;
for (index, ch) in text.char_indices() {
if ch.is_whitespace() {
if let Some(start) = token_start.take() {
sanitized.push_str(&self.sanitize_diagnostic_token(&text[start..index]));
}
sanitized.push(ch);
} else if token_start.is_none() {
token_start = Some(index);
}
}
if let Some(start) = token_start {
sanitized.push_str(&self.sanitize_diagnostic_token(&text[start..]));
}
sanitized
}
pub(crate) fn sanitize_body_preview(&self, preview: &BodyPreview<'_>) -> String {
let content_type = match preview.content_type {
Some(content_type) => match HeaderValue::from_str(content_type) {
Ok(content_type) => Some(content_type),
Err(_) => return Self::invalid_content_type_body(preview),
},
None => None,
};
let rendered = self.body_sanitizer.sanitize_body_preview(
preview.prefix(),
preview.source_len(),
content_type.as_ref(),
LOG_NAME_MATCH_MODE,
);
Self::normalize_error_truncation_suffix(rendered, preview)
}
fn sanitize_body_bytes(
&self,
body: &[u8],
limit: usize,
context: BodyLogContext,
content_type: Option<&str>,
) -> String {
let preview = BodyPreview::new(body, limit, context);
let preview = if let Some(content_type) = content_type {
preview.with_content_type(content_type)
} else {
preview
};
self.sanitize_body_preview(&preview)
}
fn sanitize_diagnostic_token(&self, token: &str) -> String {
let Some(scheme_start) = find_url_scheme_start(token) else {
return token.to_string();
};
let prefix = &token[..scheme_start];
let mut candidate_end = token.len();
loop {
let candidate = &token[scheme_start..candidate_end];
if let Ok(url) = Url::parse(candidate) {
let suffix = &token[candidate_end..];
return format!("{prefix}{}{suffix}", self.sanitize_url(&url));
}
let (previous, ch) =
previous_char_boundary(token, candidate_end).expect("candidate end is always after URL scheme start");
if previous <= scheme_start || !is_trimmable_url_suffix(ch) {
return token.to_string();
}
candidate_end = previous;
}
}
fn invalid_content_type_body(preview: &BodyPreview<'_>) -> String {
format!("{INVALID_CONTENT_TYPE_BODY_REDACTED}{}", preview.truncation_suffix())
}
fn normalize_error_truncation_suffix(rendered: String, preview: &BodyPreview<'_>) -> String {
if preview.context != BodyLogContext::ErrorResponse || !preview.is_truncated() {
return rendered;
}
let counted = format!(
"...<truncated {} bytes>",
preview.source_len().saturating_sub(preview.prefix().len())
);
if let Some(prefix) = rendered.strip_suffix(&counted) {
format!("{prefix}{}", preview.truncation_suffix())
} else {
format!("{rendered}{}", preview.truncation_suffix())
}
}
}
fn field_sanitizer(fields: &SensitiveFields) -> FieldSanitizer {
FieldSanitizer::new(FieldSanitizePolicy {
sensitive_fields: fields.clone(),
mask_policies: MaskPolicies::default(),
})
}
fn extend_sensitive_fields(target: &mut SensitiveFields, source: &SensitiveFields) {
for (field, level) in source.iter() {
target.insert(field, level);
}
}
impl Default for LogSanitizer {
fn default() -> Self {
Self::new(LogSanitizePolicy::default())
}
}
fn find_url_scheme_start(token: &str) -> Option<usize> {
match (
find_ascii_case_insensitive(token, "http://"),
find_ascii_case_insensitive(token, "https://"),
) {
(Some(http), Some(https)) => Some(http.min(https)),
(Some(http), None) => Some(http),
(None, Some(https)) => Some(https),
(None, None) => None,
}
}
fn find_ascii_case_insensitive(text: &str, needle: &str) -> Option<usize> {
let needle = needle.as_bytes();
if needle.is_empty() || text.len() < needle.len() {
return None;
}
text.as_bytes()
.windows(needle.len())
.position(|window| window.eq_ignore_ascii_case(needle))
}
fn previous_char_boundary(text: &str, end: usize) -> Option<(usize, char)> {
text[..end].char_indices().next_back()
}
fn is_trimmable_url_suffix(ch: char) -> bool {
matches!(ch, '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}' | '"' | '\'')
}