Skip to main content

qubit_redact/http/
http_redactor.rs

1// =============================================================================
2//    Copyright (c) 2025 - 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Unified immutable HTTP redaction façade.
9
10use 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
53/// Maximum number of recursively embedded HTTP URLs to redact.
54const MAX_NESTED_URL_DEPTH: usize = 8;
55
56/// Applies one immutable HTTP policy to URLs, forms, headers, and bodies.
57#[must_use = "use the redactor to produce safe HTTP diagnostics"]
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct HttpRedactor {
60    /// Complete immutable HTTP behavior snapshot.
61    policy: HttpRedactionPolicy,
62    /// Field redactor for headers.
63    header_redactor: Redactor,
64    /// Field redactor for query strings and forms.
65    query_redactor: Redactor,
66    /// Field redactor for structured body values.
67    body_redactor: Redactor,
68}
69
70impl HttpRedactor {
71    /// Creates a redactor from one immutable HTTP policy.
72    ///
73    /// # Parameters
74    ///
75    /// * `policy` - Complete HTTP policy snapshot.
76    ///
77    /// # Returns
78    ///
79    /// A unified HTTP redactor with independent field contexts.
80    #[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    /// Returns the immutable HTTP policy snapshot.
91    ///
92    /// # Returns
93    ///
94    /// The policy used by every operation on this redactor.
95    #[inline(always)]
96    pub const fn policy(&self) -> &HttpRedactionPolicy {
97        &self.policy
98    }
99
100    /// Redacts a parsed URL into log-safe text.
101    ///
102    /// User information, passwords, fragments, sensitive query values, and
103    /// non-root paths under the default policy never reach the result.
104    /// Complete HTTP URLs used as non-sensitive query values are redacted
105    /// recursively under fixed nesting and percent-decoding limits; exceeding
106    /// either limit fails closed.
107    ///
108    /// # Parameters
109    ///
110    /// * `url` - Parsed URL to redact.
111    ///
112    /// # Returns
113    ///
114    /// An owned log-safe URL representation.
115    #[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    /// Redacts every HTTP URL-looking token in diagnostic text.
124    ///
125    /// Surrounding prose and punctuation are preserved. Invalid URL-looking
126    /// tokens fail closed, and log-control characters are escaped once after
127    /// all URL replacements are complete.
128    ///
129    /// # Parameters
130    ///
131    /// * `text` - Diagnostic text that may contain absolute HTTP URLs.
132    ///
133    /// # Returns
134    ///
135    /// Owned log-safe text with recognized URLs redacted.
136    #[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    /// Parses and redacts a URL, failing closed on invalid input.
147    ///
148    /// # Parameters
149    ///
150    /// * `input` - Absolute URL text.
151    ///
152    /// # Returns
153    ///
154    /// A safe redacted URL or a fixed invalid-URL marker.
155    #[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    /// Redacts URL-encoded form text, failing closed on ambiguity.
167    ///
168    /// # Parameters
169    ///
170    /// * `input` - URL-encoded form text.
171    ///
172    /// # Returns
173    ///
174    /// A safe redacted form or a fixed invalid-form marker.
175    #[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    /// Redacts and deterministically renders all HTTP header values.
194    ///
195    /// Native sensitive values are always masked at Secret level before any
196    /// name-based allow rule can apply. Non-UTF-8 values use a fixed marker.
197    ///
198    /// # Parameters
199    ///
200    /// * `headers` - HTTP header map to redact.
201    ///
202    /// # Returns
203    ///
204    /// An opaque result whose `Display` and `Debug` expose only safe text.
205    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    /// Redacts a checked body capture under hard input and output limits.
221    ///
222    /// Parsers can observe only the prefix selected before dispatch. The final
223    /// representation is escaped first and then bounded with a complete
224    /// truncation marker.
225    ///
226    /// # Parameters
227    ///
228    /// * `capture` - Checked complete or source-truncated body capture.
229    /// * `content_type` - Optional Content-Type used for parser selection.
230    ///
231    /// # Returns
232    ///
233    /// A bounded result exposing only log-safe text and truthful metadata.
234    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    /// Redacts a checked body capture selected by optional Content-Type text.
259    ///
260    /// This accepts text from callers that do not retain a native HTTP header.
261    /// Malformed Content-Type syntax is redacted fail-closed.
262    ///
263    /// # Parameters
264    ///
265    /// * `capture` - Checked complete or source-truncated body capture.
266    /// * `content_type` - Optional Content-Type text used for parser selection.
267    ///
268    /// # Returns
269    ///
270    /// A bounded result exposing only log-safe text and truthful metadata.
271    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    /// Checks the complete header input against the diagnostic budget.
287    ///
288    /// # Parameters
289    ///
290    /// * `headers` - Header map whose names and values would be inspected.
291    ///
292    /// # Returns
293    ///
294    /// `true` when every header fits within the configured input limit.
295    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    /// Groups repeated header values under deterministically ordered names.
310    ///
311    /// # Parameters
312    ///
313    /// * `headers` - Header map whose insertion order must be preserved within
314    ///   each name.
315    ///
316    /// # Returns
317    ///
318    /// Header values grouped by their lowercase map name.
319    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    /// Writes all deterministically grouped headers to a bounded writer.
330    ///
331    /// # Parameters
332    ///
333    /// * `writer` - Bounded log-safe output destination.
334    /// * `values` - Ordered header groups to redact and render.
335    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    /// Redacts and writes every value for one header name.
357    ///
358    /// Native sensitive values always use Secret masking before name-based
359    /// policy evaluation.
360    ///
361    /// # Parameters
362    ///
363    /// * `writer` - Bounded log-safe output destination.
364    /// * `name` - Canonical HTTP header name.
365    /// * `values` - Header values in their original map order.
366    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    /// Redacts a checked body capture after normalizing Content-Type input.
398    ///
399    /// # Parameters
400    ///
401    /// * `capture` - Checked complete or source-truncated body capture.
402    /// * `content_type` - UTF-8 Content-Type text available for parser
403    ///   selection.
404    /// * `invalid_content_type` - Whether a supplied header was non-UTF-8 or
405    ///   exceeded the diagnostic input budget.
406    ///
407    /// # Returns
408    ///
409    /// A bounded result exposing only log-safe text and truthful metadata.
410    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    /// Produces an owned redacted URL before log-control escaping.
439    ///
440    /// # Parameters
441    ///
442    /// * `url` - Parsed URL to redact.
443    ///
444    /// # Returns
445    ///
446    /// An owned URL representation safe to combine with other redacted text.
447    fn redact_url_text(&self, url: &Url) -> String {
448        self.redact_url_text_at_depth(url, 0)
449    }
450
451    /// Produces a redacted URL under a bounded nested-URL recursion depth.
452    ///
453    /// # Parameters
454    ///
455    /// * `url` - Parsed URL to redact.
456    /// * `depth` - Number of enclosing URL query values already traversed.
457    ///
458    /// # Returns
459    ///
460    /// An owned URL representation safe to combine with other redacted text.
461    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    /// Redacts a complete HTTP URL embedded in a non-sensitive query value.
530    ///
531    /// # Type Parameters
532    ///
533    /// * `'a` - Lifetime of any borrowed query value retained in the result.
534    ///
535    /// # Parameters
536    ///
537    /// * `value` - Query value after ordinary field-policy redaction.
538    /// * `depth` - Number of enclosing URL query values already traversed.
539    ///
540    /// # Returns
541    ///
542    /// The original ownership form when no nested URL is present, otherwise
543    /// an owned redacted URL or fixed fail-closed marker.
544    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    /// Dispatches a bounded body slice to a supported parser.
566    ///
567    /// # Parameters
568    ///
569    /// * `bounded` - Input prefix already limited by the hard budget.
570    /// * `content_type` - Optional parser-selection text with a checked input
571    ///   bound.
572    /// * `truncated` - Whether bytes are known to follow the prefix.
573    ///
574    /// # Returns
575    ///
576    /// Unescaped redacted text, outcome status, and rendering-truncation
577    /// state.
578    #[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    /// Redacts one bounded JSON document.
663    ///
664    /// # Parameters
665    ///
666    /// * `bounded` - Complete bounded JSON bytes.
667    /// * `truncated` - Whether source bytes follow the prefix.
668    ///
669    /// # Returns
670    ///
671    /// Redacted JSON or a fixed fail-closed marker, status, and
672    /// rendering-truncation state.
673    #[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    /// Creates the fail-closed result for an invalid Content-Type.
719    ///
720    /// # Returns
721    ///
722    /// The fixed marker and its matching redaction status.
723    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    /// Redacts newline-delimited JSON from a bounded slice.
734    ///
735    /// # Parameters
736    ///
737    /// * `bounded` - Complete bounded NDJSON bytes.
738    /// * `truncated` - Whether source bytes follow the prefix.
739    ///
740    /// # Returns
741    ///
742    /// Redacted NDJSON or a fixed fail-closed marker, status, and
743    /// rendering-truncation state.
744    #[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    /// Redacts a bounded URL-encoded body.
781    ///
782    /// # Parameters
783    ///
784    /// * `bounded` - Bounded form bytes.
785    /// * `truncated` - Whether source bytes follow the prefix.
786    ///
787    /// # Returns
788    ///
789    /// Redacted form text or a fixed invalid marker, status, and complete
790    /// rendering state.
791    #[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    /// Redacts unsupported, opaque-text, or binary bounded input.
823    ///
824    /// # Parameters
825    ///
826    /// * `bounded` - Bounded fallback bytes.
827    /// * `is_text` - Whether the parsed Content-Type is an opaque text type.
828    ///
829    /// # Returns
830    ///
831    /// A policy-controlled text marker or binary summary, status, and complete
832    /// rendering state.
833    #[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    /// Escapes, bounds, and attaches exact source metadata to parser output.
866    ///
867    /// # Parameters
868    ///
869    /// * `parsed` - Unescaped redacted payload, status, and rendering state.
870    /// * `capture` - Original checked source metadata.
871    /// * `captured_len` - Number of bytes actually inspected.
872    /// * `budget_truncated` - Whether the input budget omitted captured bytes.
873    /// * `budget` - Hard output limit.
874    ///
875    /// # Returns
876    ///
877    /// A log-safe bounded body result with exact available metadata.
878    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    /// Reports whether a diagnostic input exceeds the configured hard limit.
907    ///
908    /// # Parameters
909    ///
910    /// * `input_bytes` - Number of source bytes the operation would inspect.
911    ///
912    /// # Returns
913    ///
914    /// `true` when the input must fail closed without further processing.
915    fn diagnostic_input_exceeded(&self, input_bytes: usize) -> bool {
916        input_bytes > self.policy.diagnostic_budget().max_input_bytes()
917    }
918
919    /// Returns the fixed log-safe diagnostic-limit marker.
920    ///
921    /// # Returns
922    ///
923    /// A marker containing no source prefix.
924    #[inline(always)]
925    fn diagnostic_limit_exceeded() -> LogSafeText<'static> {
926        LogSafeText::from_escaped(Cow::Borrowed(
927            markers::DIAGNOSTIC_LIMIT_EXCEEDED,
928        ))
929    }
930
931    /// Escapes and bounds one normally redacted HTTP diagnostic.
932    ///
933    /// # Parameters
934    ///
935    /// * `text` - Redacted diagnostic text before log escaping.
936    ///
937    /// # Returns
938    ///
939    /// Owned log-safe text within the configured output limit.
940    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    /// Creates a redactor from the current default HTTP policy.
953    ///
954    /// # Returns
955    ///
956    /// A fail-closed redactor with finite body limits.
957    fn default() -> Self {
958        Self::new(HttpRedactionPolicy::default())
959    }
960}
961
962/// Trims ASCII whitespace without decoding the input.
963///
964/// # Parameters
965///
966/// * `bytes` - Bytes to trim at both edges.
967///
968/// # Returns
969///
970/// A subslice without leading or trailing ASCII whitespace.
971#[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}