1use std::{
11 borrow::Cow,
12 collections::BTreeMap,
13};
14
15use http::{
16 HeaderMap,
17 HeaderValue,
18};
19use url::Url;
20
21use crate::{
22 LogSafeText,
23 Redactor,
24 Sensitivity,
25};
26
27use super::{
28 BodyBudget,
29 BodyCapture,
30 BodyRedaction,
31 BodyRedactionReason,
32 BodyRedactionStatus,
33 HttpRedactionPolicy,
34 RedactedHeaders,
35 TextBodyPolicy,
36 UrlPathPolicy,
37 internal::{
38 BoundedLogWriter,
39 ParsedBody,
40 content_type,
41 diagnostic_text,
42 form,
43 json,
44 markers,
45 multipart,
46 nested_url::{
47 self,
48 NestedUrl,
49 },
50 },
51};
52
53const MAX_NESTED_URL_DEPTH: usize = 8;
55
56#[must_use = "use the redactor to produce safe HTTP diagnostics"]
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct HttpRedactor {
60 policy: HttpRedactionPolicy,
62 header_redactor: Redactor,
64 query_redactor: Redactor,
66 body_redactor: Redactor,
68}
69
70impl HttpRedactor {
71 #[inline]
81 pub fn new(policy: HttpRedactionPolicy) -> Self {
82 Self {
83 header_redactor: Redactor::new(policy.header_policy().clone()),
84 query_redactor: Redactor::new(policy.query_policy().clone()),
85 body_redactor: Redactor::new(policy.body_policy().clone()),
86 policy,
87 }
88 }
89
90 #[inline(always)]
96 pub const fn policy(&self) -> &HttpRedactionPolicy {
97 &self.policy
98 }
99
100 #[inline]
116 pub fn redact_url(&self, url: &Url) -> LogSafeText<'static> {
117 if self.diagnostic_input_exceeded(url.as_str().len()) {
118 return Self::diagnostic_limit_exceeded();
119 }
120 self.finish_diagnostic(self.redact_url_text(url))
121 }
122
123 #[inline]
137 pub fn redact_urls_in_text(&self, text: &str) -> LogSafeText<'static> {
138 if self.diagnostic_input_exceeded(text.len()) {
139 return Self::diagnostic_limit_exceeded();
140 }
141 let redacted =
142 diagnostic_text::redact(text, |url| self.redact_url_text(url));
143 self.finish_diagnostic(redacted)
144 }
145
146 #[inline]
156 pub fn redact_url_str(&self, input: &str) -> LogSafeText<'static> {
157 if self.diagnostic_input_exceeded(input.len()) {
158 return Self::diagnostic_limit_exceeded();
159 }
160 Url::parse(input).map_or_else(
161 |_| self.finish_diagnostic(markers::INVALID_URL.to_string()),
162 |url| self.finish_diagnostic(self.redact_url_text(&url)),
163 )
164 }
165
166 #[inline]
176 pub fn redact_form(&self, input: &str) -> LogSafeText<'static> {
177 if self.diagnostic_input_exceeded(input.len()) {
178 return Self::diagnostic_limit_exceeded();
179 }
180 let output_limit = self.policy.diagnostic_budget().max_output_bytes();
181 let text = if form::is_valid(input.as_bytes()) {
182 form::redact_bounded(
183 &self.query_redactor,
184 input.as_bytes(),
185 output_limit,
186 )
187 } else {
188 markers::INVALID_FORM.to_string()
189 };
190 self.finish_diagnostic(text)
191 }
192
193 pub fn redact_headers(&self, headers: &HeaderMap) -> RedactedHeaders {
206 if !self.headers_fit_input_budget(headers) {
207 return RedactedHeaders::new(Self::diagnostic_limit_exceeded());
208 }
209
210 let mut writer = BoundedLogWriter::new(
211 self.policy.diagnostic_budget().max_output_bytes(),
212 false,
213 );
214 let values = Self::group_header_values(headers);
215 self.write_grouped_headers(&mut writer, values);
216 let (rendered, _) = writer.finish();
217 RedactedHeaders::new(LogSafeText::from_escaped(Cow::Owned(rendered)))
218 }
219
220 pub fn redact_body(
235 &self,
236 capture: BodyCapture<'_>,
237 content_type: Option<&HeaderValue>,
238 ) -> BodyRedaction {
239 let content_type_limit =
240 self.policy.diagnostic_budget().max_input_bytes();
241 let (content_type, invalid_content_type) = match content_type {
242 Some(value) if value.as_bytes().len() > content_type_limit => {
243 (None, true)
244 }
245 Some(value) => match value.to_str() {
246 Ok(value) => (Some(value), false),
247 Err(_) => (None, true),
248 },
249 None => (None, false),
250 };
251 self.redact_body_with_content_type(
252 capture,
253 content_type,
254 invalid_content_type,
255 )
256 }
257
258 pub fn redact_body_with_content_type_text(
272 &self,
273 capture: BodyCapture<'_>,
274 content_type: Option<&str>,
275 ) -> BodyRedaction {
276 let invalid_content_type = content_type.is_some_and(|value| {
277 value.len() > self.policy.diagnostic_budget().max_input_bytes()
278 });
279 self.redact_body_with_content_type(
280 capture,
281 content_type,
282 invalid_content_type,
283 )
284 }
285
286 fn headers_fit_input_budget(&self, headers: &HeaderMap) -> bool {
296 let input_limit = self.policy.diagnostic_budget().max_input_bytes();
297 let mut input_bytes = 0_usize;
298 for (name, value) in headers {
299 input_bytes = input_bytes
300 .saturating_add(name.as_str().len())
301 .saturating_add(value.as_bytes().len());
302 if input_bytes > input_limit {
303 return false;
304 }
305 }
306 true
307 }
308
309 fn group_header_values(
320 headers: &HeaderMap,
321 ) -> BTreeMap<&str, Vec<&HeaderValue>> {
322 let mut values = BTreeMap::<&str, Vec<&HeaderValue>>::new();
323 for (name, value) in headers {
324 values.entry(name.as_str()).or_default().push(value);
325 }
326 values
327 }
328
329 fn write_grouped_headers(
336 &self,
337 writer: &mut BoundedLogWriter,
338 values: BTreeMap<&str, Vec<&HeaderValue>>,
339 ) {
340 for (name_index, (name, header_values)) in
341 values.into_iter().enumerate()
342 {
343 if name_index > 0 {
344 let _ = writer.write_str("\n");
345 }
346 let _ = writer.write_str(name);
347 let _ = writer.write_str(": [");
348 self.write_header_values(writer, name, &header_values);
349 if writer.is_full() {
350 break;
351 }
352 let _ = writer.write_str("]");
353 }
354 }
355
356 fn write_header_values(
367 &self,
368 writer: &mut BoundedLogWriter,
369 name: &str,
370 values: &[&HeaderValue],
371 ) {
372 for (value_index, value) in values.iter().enumerate() {
373 if writer.is_full() {
374 break;
375 }
376 if value_index > 0 {
377 let _ = writer.write_str(", ");
378 }
379 let rendered = value.to_str().unwrap_or("<non-utf8>");
380 let remaining = writer.remaining_bytes();
381 if value.is_sensitive() {
382 let redacted = self
383 .header_redactor
384 .policy()
385 .masking()
386 .mask_bounded(Sensitivity::Secret, rendered, remaining);
387 let _ = writer.write_str(redacted.as_ref());
388 } else {
389 let redacted = self
390 .header_redactor
391 .redact_bounded(name, rendered, remaining);
392 let _ = writer.write_str(redacted.as_str());
393 }
394 }
395 }
396
397 fn redact_body_with_content_type(
411 &self,
412 capture: BodyCapture<'_>,
413 content_type: Option<&str>,
414 invalid_content_type: bool,
415 ) -> BodyRedaction {
416 let input_len = capture
417 .bytes()
418 .len()
419 .min(self.policy.body_budget().max_input_bytes());
420 let bounded = &capture.bytes()[..input_len];
421 let budget_truncated = input_len < capture.bytes().len();
422
423 let truncated = capture.is_source_truncated() || budget_truncated;
424 let parsed = if invalid_content_type {
425 Self::invalid_content_type_body()
426 } else {
427 self.redact_body_inner(bounded, content_type, truncated)
428 };
429 Self::finish_body_redaction(
430 parsed,
431 capture,
432 input_len,
433 budget_truncated,
434 self.policy.body_budget(),
435 )
436 }
437
438 fn redact_url_text(&self, url: &Url) -> String {
448 self.redact_url_text_at_depth(url, 0)
449 }
450
451 fn redact_url_text_at_depth(&self, url: &Url, depth: usize) -> String {
462 let output_limit = self.policy.diagnostic_budget().max_output_bytes();
463 let mut output = url.clone();
464 if self.policy.url_path_policy() == UrlPathPolicy::Redact
465 && output.path() != "/"
466 {
467 output.set_path("/<redacted>");
468 }
469 if !output.username().is_empty() {
470 let masked = self
471 .query_redactor
472 .policy()
473 .masking()
474 .mask_bounded(
475 Sensitivity::High,
476 output.username(),
477 output_limit,
478 )
479 .into_owned();
480 let _ = output.set_username(&masked);
481 }
482 if let Some(password) = output.password() {
483 let masked = self
484 .query_redactor
485 .policy()
486 .masking()
487 .mask_bounded(Sensitivity::Secret, password, output_limit)
488 .into_owned();
489 let _ = output.set_password(Some(&masked));
490 }
491 if let Some(fragment) = output.fragment() {
492 let masked = self
493 .query_redactor
494 .policy()
495 .masking()
496 .mask_bounded(Sensitivity::High, fragment, output_limit)
497 .into_owned();
498 output.set_fragment(Some(&masked));
499 }
500 if let Some(query) = url.query() {
501 if form::is_valid(query.as_bytes()) {
502 let query_limit = output_limit.saturating_add(1);
503 let mut redacted_query = String::new();
504 for (key, value) in url.query_pairs() {
505 let remaining =
506 query_limit.saturating_sub(redacted_query.len());
507 let value = self
508 .query_redactor
509 .redact_bounded(&key, &value, remaining)
510 .into_inner();
511 let value = self.redact_nested_url_value(value, depth);
512 if !form::append_pair_bounded(
513 &mut redacted_query,
514 &key,
515 value.as_ref(),
516 query_limit,
517 ) {
518 break;
519 }
520 }
521 output.set_query(Some(&redacted_query));
522 } else {
523 output.set_query(Some(markers::INVALID_QUERY));
524 }
525 }
526 output.to_string()
527 }
528
529 fn redact_nested_url_value<'a>(
545 &self,
546 value: Cow<'a, str>,
547 depth: usize,
548 ) -> Cow<'a, str> {
549 let raw = match value {
550 Cow::Borrowed(raw) => raw,
551 Cow::Owned(masked) => return Cow::Owned(masked),
552 };
553 match nested_url::detect(raw) {
554 NestedUrl::NotUrl => Cow::Borrowed(raw),
555 NestedUrl::Parsed(url) if depth < MAX_NESTED_URL_DEPTH => {
556 Cow::Owned(self.redact_url_text_at_depth(&url, depth + 1))
557 }
558 NestedUrl::Parsed(_) | NestedUrl::LimitExceeded => {
559 Cow::Borrowed(markers::NESTED_URL_LIMIT)
560 }
561 NestedUrl::Invalid => Cow::Borrowed(markers::INVALID_URL),
562 }
563 }
564
565 #[must_use = "redacted body text and its status must be handled together"]
579 fn redact_body_inner(
580 &self,
581 bounded: &[u8],
582 content_type: Option<&str>,
583 truncated: bool,
584 ) -> ParsedBody {
585 if bounded.is_empty() {
586 return ParsedBody::new(
587 String::new(),
588 BodyRedactionStatus::Empty,
589 false,
590 );
591 }
592 let content_type = match content_type {
593 Some(value) => match content_type::parse(value) {
594 Some(value) => Some(value),
595 None => return Self::invalid_content_type_body(),
596 },
597 None => None,
598 };
599 if let Some(content_type::ContentType::Multipart {
600 boundary,
601 require_form_data,
602 }) = &content_type
603 {
604 if truncated {
605 return ParsedBody::new(
606 markers::MULTIPART_BODY.to_string(),
607 BodyRedactionStatus::Redacted(
608 BodyRedactionReason::TruncatedMultipart,
609 ),
610 false,
611 );
612 }
613 if let Some(boundary) = boundary.as_deref()
614 && let Some((text, passed, rendered_truncated)) =
615 multipart::redact(
616 &self.body_redactor,
617 boundary,
618 *require_form_data,
619 bounded,
620 self.policy.text_body_policy(),
621 self.policy.unkeyed_json_value_policy(),
622 self.policy.body_budget().max_output_bytes(),
623 )
624 {
625 return ParsedBody::new(
626 text,
627 if passed {
628 BodyRedactionStatus::PassedThrough
629 } else {
630 BodyRedactionStatus::Structured
631 },
632 rendered_truncated,
633 );
634 }
635 return ParsedBody::new(
636 markers::MULTIPART_BODY.to_string(),
637 BodyRedactionStatus::Redacted(
638 BodyRedactionReason::InvalidMultipart,
639 ),
640 false,
641 );
642 }
643 if matches!(&content_type, Some(content_type::ContentType::Ndjson)) {
644 return self.redact_ndjson(bounded, truncated);
645 }
646 let trimmed = trim_ascii_whitespace(bounded);
647 if matches!(&content_type, Some(content_type::ContentType::Json))
648 || (content_type.is_none()
649 && matches!(trimmed.first(), Some(b'{') | Some(b'[')))
650 {
651 return self.redact_json(bounded, truncated);
652 }
653 if matches!(&content_type, Some(content_type::ContentType::Form)) {
654 return self.redact_body_form(bounded, truncated);
655 }
656 self.redact_fallback(
657 bounded,
658 matches!(&content_type, Some(content_type::ContentType::Text)),
659 )
660 }
661
662 #[must_use = "redacted JSON text and its status must be handled together"]
674 fn redact_json(&self, bounded: &[u8], truncated: bool) -> ParsedBody {
675 if truncated {
676 return ParsedBody::new(
677 markers::INVALID_OR_TRUNCATED_JSON.to_string(),
678 BodyRedactionStatus::Redacted(
679 BodyRedactionReason::InvalidOrTruncatedJson,
680 ),
681 false,
682 );
683 }
684 let Ok(mut value) = serde_json::from_slice(bounded) else {
685 return ParsedBody::new(
686 markers::INVALID_JSON.to_string(),
687 BodyRedactionStatus::Redacted(BodyRedactionReason::InvalidJson),
688 false,
689 );
690 };
691 let passed = json::redact(
692 &self.body_redactor,
693 &mut value,
694 self.policy.unkeyed_json_value_policy(),
695 self.policy.body_budget().max_output_bytes(),
696 );
697 match json::serialize_bounded(
698 &value,
699 self.policy.body_budget().max_output_bytes(),
700 ) {
701 Some((text, rendered_truncated)) => ParsedBody::new(
702 text,
703 if passed {
704 BodyRedactionStatus::PassedThrough
705 } else {
706 BodyRedactionStatus::Structured
707 },
708 rendered_truncated,
709 ),
710 None => ParsedBody::new(
711 markers::INVALID_JSON.to_string(),
712 BodyRedactionStatus::Redacted(BodyRedactionReason::InvalidJson),
713 false,
714 ),
715 }
716 }
717
718 fn invalid_content_type_body() -> ParsedBody {
724 ParsedBody::new(
725 markers::INVALID_CONTENT_TYPE.to_string(),
726 BodyRedactionStatus::Redacted(
727 BodyRedactionReason::InvalidContentType,
728 ),
729 false,
730 )
731 }
732
733 #[must_use = "redacted NDJSON text and its status must be handled together"]
745 fn redact_ndjson(&self, bounded: &[u8], truncated: bool) -> ParsedBody {
746 if truncated {
747 return ParsedBody::new(
748 markers::INVALID_OR_TRUNCATED_NDJSON.to_string(),
749 BodyRedactionStatus::Redacted(
750 BodyRedactionReason::InvalidOrTruncatedNdjson,
751 ),
752 false,
753 );
754 }
755 match json::redact_ndjson(
756 &self.body_redactor,
757 bounded,
758 self.policy.unkeyed_json_value_policy(),
759 self.policy.body_budget().max_output_bytes(),
760 ) {
761 Some((output, passed, rendered_truncated)) => ParsedBody::new(
762 output,
763 if passed {
764 BodyRedactionStatus::PassedThrough
765 } else {
766 BodyRedactionStatus::Structured
767 },
768 rendered_truncated,
769 ),
770 None => ParsedBody::new(
771 markers::INVALID_NDJSON.to_string(),
772 BodyRedactionStatus::Redacted(
773 BodyRedactionReason::InvalidNdjson,
774 ),
775 false,
776 ),
777 }
778 }
779
780 #[must_use = "redacted form text and its status must be handled together"]
792 fn redact_body_form(&self, bounded: &[u8], truncated: bool) -> ParsedBody {
793 if truncated {
794 return ParsedBody::new(
795 markers::INVALID_OR_TRUNCATED_FORM.to_string(),
796 BodyRedactionStatus::Redacted(
797 BodyRedactionReason::InvalidOrTruncatedFormUrlEncoded,
798 ),
799 false,
800 );
801 }
802 if !form::is_valid(bounded) {
803 return ParsedBody::new(
804 markers::INVALID_FORM.to_string(),
805 BodyRedactionStatus::Redacted(
806 BodyRedactionReason::InvalidFormUrlEncoded,
807 ),
808 false,
809 );
810 }
811 ParsedBody::new(
812 form::redact_bounded(
813 &self.body_redactor,
814 bounded,
815 self.policy.body_budget().max_output_bytes(),
816 ),
817 BodyRedactionStatus::Structured,
818 false,
819 )
820 }
821
822 #[must_use = "fallback text and its status must be handled together"]
834 fn redact_fallback(&self, bounded: &[u8], is_text: bool) -> ParsedBody {
835 match std::str::from_utf8(bounded) {
836 Err(_) => ParsedBody::new(
837 format!("<binary {} bytes>", bounded.len()),
838 BodyRedactionStatus::Binary,
839 false,
840 ),
841 Ok(text) if is_text => match self.policy.text_body_policy() {
842 TextBodyPolicy::Redact => ParsedBody::new(
843 markers::TEXT_BODY.to_string(),
844 BodyRedactionStatus::Redacted(
845 BodyRedactionReason::OpaqueText,
846 ),
847 false,
848 ),
849 TextBodyPolicy::PassThrough => ParsedBody::new(
850 text.to_string(),
851 BodyRedactionStatus::PassedThrough,
852 false,
853 ),
854 },
855 Ok(_) => ParsedBody::new(
856 markers::UNSUPPORTED_BODY.to_string(),
857 BodyRedactionStatus::Redacted(
858 BodyRedactionReason::UnsupportedMediaType,
859 ),
860 false,
861 ),
862 }
863 }
864
865 fn finish_body_redaction(
879 parsed: ParsedBody,
880 capture: BodyCapture<'_>,
881 captured_len: usize,
882 budget_truncated: bool,
883 budget: BodyBudget,
884 ) -> BodyRedaction {
885 let (parsed_text, status, rendered_truncated) = parsed.into_parts();
886 let source_truncated = capture.is_source_truncated()
887 || budget_truncated
888 || rendered_truncated;
889 let mut writer =
890 BoundedLogWriter::new(budget.max_output_bytes(), source_truncated);
891 let _ = writer.write_str(&parsed_text);
892 let (text, truncated) = writer.finish();
893 let source_len = capture.total_len();
894 let omitted_len =
895 source_len.map(|total| total.saturating_sub(captured_len));
896 BodyRedaction::new(
897 text,
898 status,
899 captured_len,
900 source_len,
901 omitted_len,
902 truncated,
903 )
904 }
905
906 fn diagnostic_input_exceeded(&self, input_bytes: usize) -> bool {
916 input_bytes > self.policy.diagnostic_budget().max_input_bytes()
917 }
918
919 #[inline(always)]
925 fn diagnostic_limit_exceeded() -> LogSafeText<'static> {
926 LogSafeText::from_escaped(Cow::Borrowed(
927 markers::DIAGNOSTIC_LIMIT_EXCEEDED,
928 ))
929 }
930
931 fn finish_diagnostic(&self, text: String) -> LogSafeText<'static> {
941 let mut writer = BoundedLogWriter::new(
942 self.policy.diagnostic_budget().max_output_bytes(),
943 false,
944 );
945 let _ = writer.write_str(&text);
946 let (text, _) = writer.finish();
947 LogSafeText::from_escaped(Cow::Owned(text))
948 }
949}
950
951impl Default for HttpRedactor {
952 fn default() -> Self {
958 Self::new(HttpRedactionPolicy::default())
959 }
960}
961
962#[must_use]
972fn trim_ascii_whitespace(mut bytes: &[u8]) -> &[u8] {
973 while bytes.first().is_some_and(u8::is_ascii_whitespace) {
974 bytes = &bytes[1..];
975 }
976 while bytes.last().is_some_and(u8::is_ascii_whitespace) {
977 bytes = &bytes[..bytes.len() - 1];
978 }
979 bytes
980}