use crate::NameMatchMode;
use super::{
content_type,
http_body_sanitizer::HttpBodySanitizer,
redaction_markers::{
MULTIPART_FILE_PART_REDACTED,
MULTIPART_PART_REDACTED,
MULTIPART_UNNAMED_FIELD,
},
};
pub(super) fn sanitize_multipart(
sanitizer: &HttpBodySanitizer,
content_type: Option<&str>,
bytes: &[u8],
match_mode: NameMatchMode,
) -> Option<String> {
let boundary = content_type::multipart_boundary(content_type?)?;
let text = std::str::from_utf8(bytes).ok()?;
let segments = multipart_part_segments(text, &boundary)?;
let mut lines = Vec::with_capacity(segments.len());
for segment in segments {
lines.push(sanitize_multipart_part(sanitizer, segment, match_mode)?);
}
if lines.is_empty() {
return Some("<multipart>\n</multipart>".to_string());
}
Some(format!("<multipart>\n{}\n</multipart>", lines.join("\n")))
}
fn sanitize_multipart_part(
sanitizer: &HttpBodySanitizer,
segment: &str,
match_mode: NameMatchMode,
) -> Option<String> {
let (headers, body) = split_multipart_headers_and_body(segment)?;
let mut content_disposition = None;
let mut content_type = None;
for line in headers.lines().filter(|line| !line.trim().is_empty()) {
let (header_name, header_value) = line.split_once(':')?;
let header_name = header_name.trim();
let header_value = header_value.trim();
if header_name.eq_ignore_ascii_case("content-disposition") {
content_disposition = Some(header_value);
} else if header_name.eq_ignore_ascii_case("content-type") {
content_type = Some(header_value);
}
}
let name = content_disposition.and_then(|value| content_type::parameter(value, "name"));
let filename = content_disposition.and_then(|value| {
content_type::parameter(value, "filename")
.or_else(|| content_type::parameter(value, "filename*"))
});
let field_name = name.as_deref().unwrap_or(MULTIPART_UNNAMED_FIELD);
let value = sanitize_multipart_part_value(
sanitizer,
field_name,
filename.as_deref(),
content_type,
body,
match_mode,
);
Some(format!("{field_name}={value}"))
}
fn sanitize_multipart_part_value(
sanitizer: &HttpBodySanitizer,
field_name: &str,
filename: Option<&str>,
content_type: Option<&str>,
body: &str,
match_mode: NameMatchMode,
) -> String {
if sanitizer
.field_sanitizer()
.sensitivity_for_name(field_name, match_mode)
.is_some()
{
return sanitizer
.field_sanitizer()
.sanitize_value(field_name, body, match_mode)
.into_owned();
}
if filename.is_some() {
return MULTIPART_FILE_PART_REDACTED.to_string();
}
if field_name == MULTIPART_UNNAMED_FIELD {
return MULTIPART_PART_REDACTED.to_string();
}
let Some(content_type) = content_type else {
return body.to_string();
};
if content_type::is_json(content_type) {
return sanitizer
.sanitize_json(body.as_bytes(), match_mode)
.unwrap_or_else(|| MULTIPART_PART_REDACTED.to_string());
}
if content_type::is_ndjson(content_type) {
return sanitizer
.sanitize_ndjson(body.as_bytes(), match_mode)
.unwrap_or_else(|| MULTIPART_PART_REDACTED.to_string());
}
if content_type::is_form_urlencoded(content_type) {
return sanitizer.sanitize_form(body.as_bytes(), match_mode);
}
if content_type::is_text(content_type) {
return body.to_string();
}
MULTIPART_PART_REDACTED.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MultipartDelimiter {
Part,
Closing,
}
fn multipart_part_segments<'a>(text: &'a str, boundary: &str) -> Option<Vec<&'a str>> {
let mut current_start = None;
let mut segments = Vec::new();
let mut position = 0;
while position < text.len() {
let (line_start, line_end, next_position) = next_line_bounds(text, position);
let line = &text[line_start..line_end];
let Some(delimiter) = multipart_delimiter(line, boundary) else {
position = next_position;
continue;
};
if let Some(start) = current_start {
let segment = strip_one_trailing_line_ending(&text[start..line_start]);
if !segment.trim().is_empty() {
segments.push(segment);
}
}
if delimiter == MultipartDelimiter::Closing {
if text[next_position..].trim().is_empty() {
return Some(segments);
}
return None;
}
current_start = Some(next_position);
position = next_position;
}
None
}
fn next_line_bounds(text: &str, position: usize) -> (usize, usize, usize) {
if let Some(relative_end) = text[position..].find('\n') {
let line_end = position + relative_end;
let trimmed_end = line_end
.checked_sub(1)
.filter(|index| text.as_bytes()[*index] == b'\r')
.unwrap_or(line_end);
return (position, trimmed_end, line_end + 1);
}
(position, text.len(), text.len())
}
fn multipart_delimiter(line: &str, boundary: &str) -> Option<MultipartDelimiter> {
let delimiter = format!("--{boundary}");
if line == delimiter {
Some(MultipartDelimiter::Part)
} else if line == format!("{delimiter}--") {
Some(MultipartDelimiter::Closing)
} else {
None
}
}
fn split_multipart_headers_and_body(segment: &str) -> Option<(&str, &str)> {
if let Some(index) = segment.find("\r\n\r\n") {
return Some((&segment[..index], &segment[index + 4..]));
}
if let Some(index) = segment.find("\n\n") {
return Some((&segment[..index], &segment[index + 2..]));
}
None
}
fn strip_one_trailing_line_ending(value: &str) -> &str {
value
.strip_suffix("\r\n")
.or_else(|| value.strip_suffix('\n'))
.unwrap_or(value)
}