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::borrow::Cow;
11
12mod body;
13mod diagnostics;
14mod headers;
15mod url_rules;
16
17use http::{
18    HeaderMap,
19    HeaderValue,
20};
21use url::Url;
22
23use crate::policy::OutputCharge;
24use crate::{
25    LogSafeText,
26    RedactionPolicy,
27    RedactionSession,
28    Sensitivity,
29};
30
31use super::{
32    BodyBudget,
33    BodyCapture,
34    BodyRedaction,
35    BodyRedactionReason,
36    BodyRedactionStatus,
37    FieldRedactor,
38    RedactedHeaders,
39    TextBodyPolicy,
40    UrlPathPolicy,
41    internal::{
42        BoundedLogWriter,
43        ParsedBody,
44        content_type,
45        diagnostic_text,
46        form,
47        json,
48        markers,
49        multipart,
50        nested_url::{
51            self,
52            NestedUrl,
53        },
54    },
55};
56
57/// Applies one immutable HTTP policy to URLs, forms, headers, and bodies.
58#[must_use = "use the redactor to produce safe HTTP diagnostics"]
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct HttpRedactor {
61    /// Complete immutable HTTP behavior snapshot.
62    policy: RedactionPolicy,
63}
64
65impl HttpRedactor {
66    /// Creates a redactor from one immutable HTTP policy.
67    ///
68    /// # Parameters
69    ///
70    /// * `policy` - Complete HTTP policy snapshot.
71    ///
72    /// # Returns
73    ///
74    /// A unified HTTP redactor with independent field contexts.
75    #[inline]
76    pub fn new(policy: RedactionPolicy) -> Self {
77        Self { policy }
78    }
79
80    /// Creates a redactor with the strict policy for untrusted HTTP data.
81    ///
82    /// The strict snapshot masks unknown structured fields and redacts
83    /// non-root URL paths while retaining the configured resource limits.
84    #[inline]
85    pub fn strict() -> Self {
86        Self::new(RedactionPolicy::strict())
87    }
88
89    /// Returns the immutable HTTP policy snapshot.
90    ///
91    /// # Returns
92    ///
93    /// The policy used by every operation on this redactor.
94    #[inline(always)]
95    pub const fn policy(&self) -> &RedactionPolicy {
96        &self.policy
97    }
98
99    /// Borrows the header field-rule executor for the current operation.
100    fn header_field_redactor(&self) -> FieldRedactor<'_> {
101        FieldRedactor::new(
102            self.policy.rules(),
103            self.policy.header_rules(),
104            self.policy.masking(),
105        )
106    }
107
108    /// Borrows the query field-rule executor for the current operation.
109    fn query_field_redactor(&self) -> FieldRedactor<'_> {
110        FieldRedactor::new(
111            self.policy.rules(),
112            self.policy.query_rules(),
113            self.policy.masking(),
114        )
115    }
116
117    /// Borrows the structured-body field-rule executor for the current
118    /// operation.
119    fn body_field_redactor(&self) -> FieldRedactor<'_> {
120        FieldRedactor::new(
121            self.policy.rules(),
122            self.policy.body_rules(),
123            self.policy.masking(),
124        )
125    }
126
127    /// Redacts a parsed URL into log-safe text.
128    ///
129    /// User information, passwords, fragments, sensitive query values, and
130    /// non-root paths under a strict policy never reach the result.
131    /// Complete HTTP URLs used as non-sensitive query values are redacted
132    /// recursively under fixed nesting and percent-decoding limits; exceeding
133    /// either limit fails closed.
134    ///
135    /// # Parameters
136    ///
137    /// * `url` - Parsed URL to redact.
138    ///
139    /// # Returns
140    ///
141    /// An owned log-safe URL representation.
142    #[inline]
143    pub fn redact_url(&self, url: &Url) -> LogSafeText<'static> {
144        if self.diagnostic_input_exceeded(url.as_str().len()) {
145            return Self::diagnostic_limit_exceeded();
146        }
147        self.finish_diagnostic(self.redact_url_text(url))
148    }
149
150    /// Redacts every HTTP URL-looking token in diagnostic text.
151    ///
152    /// Surrounding prose and punctuation are preserved. Invalid URL-looking
153    /// tokens fail closed, and log-control characters are escaped once after
154    /// all URL replacements are complete.
155    ///
156    /// # Parameters
157    ///
158    /// * `text` - Diagnostic text that may contain absolute HTTP URLs.
159    ///
160    /// # Returns
161    ///
162    /// Owned log-safe text with recognized URLs redacted.
163    #[inline]
164    pub fn redact_urls_in_text(&self, text: &str) -> LogSafeText<'static> {
165        if self.diagnostic_input_exceeded(text.len()) {
166            return Self::diagnostic_limit_exceeded();
167        }
168        let redacted =
169            diagnostic_text::redact(text, |url| self.redact_url_text(url));
170        self.finish_diagnostic(redacted)
171    }
172
173    /// Parses and redacts a URL, failing closed on invalid input.
174    ///
175    /// # Parameters
176    ///
177    /// * `input` - Absolute URL text.
178    ///
179    /// # Returns
180    ///
181    /// A safe redacted URL or a fixed invalid-URL marker.
182    #[inline]
183    pub fn redact_url_str(&self, input: &str) -> LogSafeText<'static> {
184        if self.diagnostic_input_exceeded(input.len()) {
185            return Self::diagnostic_limit_exceeded();
186        }
187        Url::parse(input).map_or_else(
188            |_| self.finish_diagnostic(markers::INVALID_URL.to_string()),
189            |url| self.finish_diagnostic(self.redact_url_text(&url)),
190        )
191    }
192
193    /// Redacts URL-encoded form text, failing closed on ambiguity.
194    ///
195    /// # Parameters
196    ///
197    /// * `input` - URL-encoded form text.
198    ///
199    /// # Returns
200    ///
201    /// A safe redacted form or a fixed invalid-form marker.
202    #[inline]
203    pub fn redact_form(&self, input: &str) -> LogSafeText<'static> {
204        if self.diagnostic_input_exceeded(input.len()) {
205            return Self::diagnostic_limit_exceeded();
206        }
207        let output_limit =
208            self.policy.limits().diagnostic_event().max_output_bytes();
209        let text = if form::is_valid(input.as_bytes()) {
210            form::redact_bounded(
211                &FieldRedactor::new(
212                    self.policy.rules(),
213                    self.policy.query_rules(),
214                    self.policy.masking(),
215                ),
216                input.as_bytes(),
217                output_limit,
218            )
219        } else {
220            markers::INVALID_FORM.to_string()
221        };
222        self.finish_diagnostic(text)
223    }
224
225    /// Redacts and deterministically renders all HTTP header values.
226    ///
227    /// Native sensitive values are always masked at Secret level before any
228    /// name-based allow rule can apply. Non-UTF-8 values use a fixed marker.
229    ///
230    /// # Parameters
231    ///
232    /// * `headers` - HTTP header map to redact.
233    ///
234    /// # Returns
235    ///
236    /// An opaque result whose `Display` and `Debug` expose only safe text.
237    pub fn redact_headers(&self, headers: &HeaderMap) -> RedactedHeaders {
238        if !self.headers_fit_input_budget(headers) {
239            return RedactedHeaders::new(Self::diagnostic_limit_exceeded());
240        }
241
242        let mut writer = BoundedLogWriter::new(
243            self.policy.limits().diagnostic_event().max_output_bytes(),
244            false,
245        );
246        let values = headers::group_values(headers);
247        self.write_grouped_headers(&mut writer, values);
248        let (rendered, _) = writer.finish();
249        RedactedHeaders::new(LogSafeText::from_escaped(Cow::Owned(rendered)))
250    }
251
252    /// Redacts a checked body capture under hard input and output limits.
253    ///
254    /// Parsers can observe only the prefix selected before dispatch. The final
255    /// representation is escaped first and then bounded with a complete
256    /// truncation marker.
257    ///
258    /// # Parameters
259    ///
260    /// * `capture` - Checked complete or source-truncated body capture.
261    /// * `content_type` - Optional Content-Type used for parser selection.
262    ///
263    /// # Returns
264    ///
265    /// A bounded result exposing only log-safe text and truthful metadata.
266    pub fn redact_body(
267        &self,
268        capture: BodyCapture<'_>,
269        content_type: Option<&HeaderValue>,
270    ) -> BodyRedaction {
271        let content_type_limit =
272            self.policy.limits().diagnostic_event().max_input_bytes();
273        let (content_type, invalid_content_type) = match content_type {
274            Some(value) if value.as_bytes().len() > content_type_limit => {
275                (None, true)
276            }
277            Some(value) => match value.to_str() {
278                Ok(value) => (Some(value), false),
279                Err(_) => (None, true),
280            },
281            None => (None, false),
282        };
283        self.redact_body_with_content_type(
284            capture,
285            content_type,
286            invalid_content_type,
287        )
288    }
289
290    /// Redacts a checked body capture selected by optional Content-Type text.
291    ///
292    /// This accepts text from callers that do not retain a native HTTP header.
293    /// Malformed Content-Type syntax is redacted fail-closed.
294    ///
295    /// # Parameters
296    ///
297    /// * `capture` - Checked complete or source-truncated body capture.
298    /// * `content_type` - Optional Content-Type text used for parser selection.
299    ///
300    /// # Returns
301    ///
302    /// A bounded result exposing only log-safe text and truthful metadata.
303    pub fn redact_body_with_content_type_text(
304        &self,
305        capture: BodyCapture<'_>,
306        content_type: Option<&str>,
307    ) -> BodyRedaction {
308        let invalid_content_type = content_type.is_some_and(|value| {
309            value.len()
310                > self.policy.limits().diagnostic_event().max_input_bytes()
311        });
312        self.redact_body_with_content_type(
313            capture,
314            content_type,
315            invalid_content_type,
316        )
317    }
318
319    /// Redacts a URL while consuming the supplied diagnostic session.
320    #[must_use = "use the session-bounded URL result"]
321    pub fn redact_url_with_session(
322        &self,
323        url: &Url,
324        session: &RedactionSession<'_>,
325    ) -> LogSafeText<'static> {
326        if !session.consume_input(url.as_str().len()) {
327            return session_diagnostic_limit_exceeded(session);
328        }
329        charge_session_output(session, self.redact_url(url))
330    }
331
332    /// Redacts URL-looking tokens while consuming a shared diagnostic session.
333    #[must_use = "use the session-bounded URL result"]
334    pub fn redact_urls_in_text_with_session(
335        &self,
336        text: &str,
337        session: &RedactionSession<'_>,
338    ) -> LogSafeText<'static> {
339        if !session.consume_input(text.len()) {
340            return session_diagnostic_limit_exceeded(session);
341        }
342        charge_session_output(session, self.redact_urls_in_text(text))
343    }
344
345    /// Parses and redacts a URL while consuming a shared diagnostic session.
346    #[must_use = "use the session-bounded URL result"]
347    pub fn redact_url_str_with_session(
348        &self,
349        input: &str,
350        session: &RedactionSession<'_>,
351    ) -> LogSafeText<'static> {
352        if !session.consume_input(input.len()) {
353            return session_diagnostic_limit_exceeded(session);
354        }
355        charge_session_output(session, self.redact_url_str(input))
356    }
357
358    /// Redacts a form while consuming a shared diagnostic session.
359    #[must_use = "use the session-bounded form result"]
360    pub fn redact_form_with_session(
361        &self,
362        input: &str,
363        session: &RedactionSession<'_>,
364    ) -> LogSafeText<'static> {
365        if !session.consume_input(input.len()) {
366            return session_diagnostic_limit_exceeded(session);
367        }
368        charge_session_output(session, self.redact_form(input))
369    }
370
371    /// Redacts headers while consuming a shared diagnostic session.
372    #[must_use = "use the session-bounded header result"]
373    pub fn redact_headers_with_session(
374        &self,
375        headers: &HeaderMap,
376        session: &RedactionSession<'_>,
377    ) -> RedactedHeaders {
378        let input_bytes = headers
379            .iter()
380            .map(|(name, value)| {
381                name.as_str().len().saturating_add(value.as_bytes().len())
382            })
383            .fold(0_usize, usize::saturating_add);
384        if !session.consume_input(input_bytes) {
385            return RedactedHeaders::new(session_diagnostic_limit_exceeded(
386                session,
387            ));
388        }
389        charge_header_output(session, self.redact_headers(headers))
390    }
391
392    /// Redacts a body while consuming a shared diagnostic session.
393    #[must_use = "use the session-bounded body result"]
394    pub fn redact_body_with_session(
395        &self,
396        capture: BodyCapture<'_>,
397        content_type: Option<&HeaderValue>,
398        session: &RedactionSession<'_>,
399    ) -> BodyRedaction {
400        let input_bytes = capture.bytes().len().saturating_add(
401            content_type.map_or(0, |value| value.as_bytes().len()),
402        );
403        if !session.consume_input(input_bytes) {
404            let text = session_diagnostic_limit_exceeded(session).into_owned();
405            return session_limited_body(capture, text);
406        }
407        charge_body_output(session, self.redact_body(capture, content_type))
408    }
409
410    /// Redacts a checked body capture after normalizing Content-Type input.
411    ///
412    /// # Parameters
413    ///
414    /// * `capture` - Checked complete or source-truncated body capture.
415    /// * `content_type` - UTF-8 Content-Type text available for parser
416    ///   selection.
417    /// * `invalid_content_type` - Whether a supplied header was non-UTF-8 or
418    ///   exceeded the diagnostic input budget.
419    ///
420    /// # Returns
421    ///
422    /// A bounded result exposing only log-safe text and truthful metadata.
423    fn redact_body_with_content_type(
424        &self,
425        capture: BodyCapture<'_>,
426        content_type: Option<&str>,
427        invalid_content_type: bool,
428    ) -> BodyRedaction {
429        let input_len = capture
430            .bytes()
431            .len()
432            .min(self.policy.body_budget().max_input_bytes());
433        let bounded = &capture.bytes()[..input_len];
434        let budget_truncated = input_len < capture.bytes().len();
435
436        let truncated = capture.is_source_truncated() || budget_truncated;
437        let parsed = if invalid_content_type {
438            Self::invalid_content_type_body()
439        } else {
440            self.redact_body_inner(bounded, content_type, truncated)
441        };
442        Self::finish_body_redaction(
443            parsed,
444            capture,
445            input_len,
446            budget_truncated,
447            self.policy.body_budget(),
448        )
449    }
450
451    /// Produces an owned redacted URL before log-control escaping.
452    ///
453    /// # Parameters
454    ///
455    /// * `url` - Parsed URL to redact.
456    ///
457    /// # Returns
458    ///
459    /// An owned URL representation safe to combine with other redacted text.
460    fn redact_url_text(&self, url: &Url) -> String {
461        self.redact_url_text_at_depth(url, 0)
462    }
463
464    /// Produces a redacted URL under a bounded nested-URL recursion depth.
465    ///
466    /// # Parameters
467    ///
468    /// * `url` - Parsed URL to redact.
469    /// * `depth` - Number of enclosing URL query values already traversed.
470    ///
471    /// # Returns
472    ///
473    /// An owned URL representation safe to combine with other redacted text.
474    fn redact_url_text_at_depth(&self, url: &Url, depth: usize) -> String {
475        let output_limit =
476            self.policy.limits().diagnostic_event().max_output_bytes();
477        let mut output = url.clone();
478        if self.policy.url_path_policy() == UrlPathPolicy::Redact
479            && output.path() != "/"
480        {
481            output.set_path("/<redacted>");
482        }
483        if !output.username().is_empty() {
484            let masked = self
485                .query_field_redactor()
486                .mask_bounded(
487                    Sensitivity::High,
488                    output.username(),
489                    output_limit,
490                )
491                .into_owned();
492            let _ = output.set_username(&masked);
493        }
494        if let Some(password) = output.password() {
495            let masked = self
496                .query_field_redactor()
497                .mask_bounded(Sensitivity::Secret, password, output_limit)
498                .into_owned();
499            let _ = output.set_password(Some(&masked));
500        }
501        if let Some(fragment) = output.fragment() {
502            let masked = self
503                .query_field_redactor()
504                .mask_bounded(Sensitivity::High, fragment, output_limit)
505                .into_owned();
506            output.set_fragment(Some(&masked));
507        }
508        if let Some(query) = url.query() {
509            if form::is_valid(query.as_bytes()) {
510                let query_limit = output_limit.saturating_add(1);
511                let mut redacted_query = String::new();
512                for (key, value) in url.query_pairs() {
513                    let remaining =
514                        query_limit.saturating_sub(redacted_query.len());
515                    let value = self
516                        .query_field_redactor()
517                        .redact_bounded(&key, &value, remaining)
518                        .into_inner();
519                    let value = self.redact_nested_url_value(value, depth);
520                    if !form::append_pair_bounded(
521                        &mut redacted_query,
522                        &key,
523                        value.as_ref(),
524                        query_limit,
525                    ) {
526                        break;
527                    }
528                }
529                output.set_query(Some(&redacted_query));
530            } else {
531                output.set_query(Some(markers::INVALID_QUERY));
532            }
533        }
534        output.to_string()
535    }
536
537    /// Redacts a complete HTTP URL embedded in a non-sensitive query value.
538    ///
539    /// # Type Parameters
540    ///
541    /// * `'a` - Lifetime of any borrowed query value retained in the result.
542    ///
543    /// # Parameters
544    ///
545    /// * `value` - Query value after ordinary field-policy redaction.
546    /// * `depth` - Number of enclosing URL query values already traversed.
547    ///
548    /// # Returns
549    ///
550    /// The original ownership form when no nested URL is present, otherwise
551    /// an owned redacted URL or fixed fail-closed marker.
552    fn redact_nested_url_value<'a>(
553        &self,
554        value: Cow<'a, str>,
555        depth: usize,
556    ) -> Cow<'a, str> {
557        let raw = match value {
558            Cow::Borrowed(raw) => raw,
559            Cow::Owned(masked) => return Cow::Owned(masked),
560        };
561        match nested_url::detect(raw) {
562            NestedUrl::NotUrl => Cow::Borrowed(raw),
563            NestedUrl::Parsed(url)
564                if depth < url_rules::MAX_NESTED_URL_DEPTH =>
565            {
566                Cow::Owned(self.redact_url_text_at_depth(&url, depth + 1))
567            }
568            NestedUrl::Parsed(_) | NestedUrl::LimitExceeded => {
569                Cow::Borrowed(markers::NESTED_URL_LIMIT)
570            }
571            NestedUrl::Invalid => Cow::Borrowed(markers::INVALID_URL),
572        }
573    }
574
575    /// Dispatches a bounded body slice to a supported parser.
576    ///
577    /// # Parameters
578    ///
579    /// * `bounded` - Input prefix already limited by the hard budget.
580    /// * `content_type` - Optional parser-selection text with a checked input
581    ///   bound.
582    /// * `truncated` - Whether bytes are known to follow the prefix.
583    ///
584    /// # Returns
585    ///
586    /// Unescaped redacted text, outcome status, and rendering-truncation
587    /// state.
588    #[must_use = "redacted body text and its status must be handled together"]
589    fn redact_body_inner(
590        &self,
591        bounded: &[u8],
592        content_type: Option<&str>,
593        truncated: bool,
594    ) -> ParsedBody {
595        if bounded.is_empty() {
596            return ParsedBody::new(
597                String::new(),
598                BodyRedactionStatus::Empty,
599                false,
600            );
601        }
602        let content_type = match content_type {
603            Some(value) => match content_type::parse(value) {
604                Some(value) => Some(value),
605                None => return Self::invalid_content_type_body(),
606            },
607            None => None,
608        };
609        if let Some(content_type::ContentType::Multipart {
610            boundary,
611            require_form_data,
612        }) = &content_type
613        {
614            if truncated {
615                return ParsedBody::new(
616                    markers::MULTIPART_BODY.to_string(),
617                    BodyRedactionStatus::Redacted(
618                        BodyRedactionReason::TruncatedMultipart,
619                    ),
620                    false,
621                );
622            }
623            if let Some(boundary) = boundary.as_deref()
624                && let Some((text, passed, rendered_truncated)) =
625                    multipart::redact(
626                        &self.body_field_redactor(),
627                        boundary,
628                        *require_form_data,
629                        bounded,
630                        &self.policy,
631                    )
632            {
633                return ParsedBody::new(
634                    text,
635                    if passed {
636                        BodyRedactionStatus::PassedThrough
637                    } else {
638                        BodyRedactionStatus::Structured
639                    },
640                    rendered_truncated,
641                );
642            }
643            return ParsedBody::new(
644                markers::MULTIPART_BODY.to_string(),
645                BodyRedactionStatus::Redacted(
646                    BodyRedactionReason::InvalidMultipart,
647                ),
648                false,
649            );
650        }
651        if matches!(&content_type, Some(content_type::ContentType::Ndjson)) {
652            return self.redact_ndjson(bounded, truncated);
653        }
654        let trimmed = body::trim_ascii_whitespace(bounded);
655        if matches!(&content_type, Some(content_type::ContentType::Json))
656            || (content_type.is_none()
657                && matches!(trimmed.first(), Some(b'{') | Some(b'[')))
658        {
659            return self.redact_json(bounded, truncated);
660        }
661        if matches!(&content_type, Some(content_type::ContentType::Form)) {
662            return self.redact_body_form(bounded, truncated);
663        }
664        self.redact_fallback(
665            bounded,
666            matches!(&content_type, Some(content_type::ContentType::Text)),
667        )
668    }
669
670    /// Redacts one bounded JSON document.
671    ///
672    /// # Parameters
673    ///
674    /// * `bounded` - Complete bounded JSON bytes.
675    /// * `truncated` - Whether source bytes follow the prefix.
676    ///
677    /// # Returns
678    ///
679    /// Redacted JSON or a fixed fail-closed marker, status, and
680    /// rendering-truncation state.
681    #[must_use = "redacted JSON text and its status must be handled together"]
682    fn redact_json(&self, bounded: &[u8], truncated: bool) -> ParsedBody {
683        if truncated {
684            return ParsedBody::new(
685                markers::INVALID_OR_TRUNCATED_JSON.to_string(),
686                BodyRedactionStatus::Redacted(
687                    BodyRedactionReason::InvalidOrTruncatedJson,
688                ),
689                false,
690            );
691        }
692        let Ok(mut value) = serde_json::from_slice(bounded) else {
693            return ParsedBody::new(
694                markers::INVALID_JSON.to_string(),
695                BodyRedactionStatus::Redacted(BodyRedactionReason::InvalidJson),
696                false,
697            );
698        };
699        let passed = json::redact(
700            &self.body_field_redactor(),
701            &mut value,
702            self.policy.json_depth_budget(),
703            self.policy.unkeyed_json_value_policy(),
704            self.policy.body_budget().max_output_bytes(),
705        );
706        match json::serialize_bounded(
707            &value,
708            self.policy.body_budget().max_output_bytes(),
709        ) {
710            Some((text, rendered_truncated)) => ParsedBody::new(
711                text,
712                if passed {
713                    BodyRedactionStatus::PassedThrough
714                } else {
715                    BodyRedactionStatus::Structured
716                },
717                rendered_truncated,
718            ),
719            None => ParsedBody::new(
720                markers::INVALID_JSON.to_string(),
721                BodyRedactionStatus::Redacted(BodyRedactionReason::InvalidJson),
722                false,
723            ),
724        }
725    }
726
727    /// Creates the fail-closed result for an invalid Content-Type.
728    ///
729    /// # Returns
730    ///
731    /// The fixed marker and its matching redaction status.
732    fn invalid_content_type_body() -> ParsedBody {
733        ParsedBody::new(
734            markers::INVALID_CONTENT_TYPE.to_string(),
735            BodyRedactionStatus::Redacted(
736                BodyRedactionReason::InvalidContentType,
737            ),
738            false,
739        )
740    }
741
742    /// Redacts newline-delimited JSON from a bounded slice.
743    ///
744    /// # Parameters
745    ///
746    /// * `bounded` - Complete bounded NDJSON bytes.
747    /// * `truncated` - Whether source bytes follow the prefix.
748    ///
749    /// # Returns
750    ///
751    /// Redacted NDJSON or a fixed fail-closed marker, status, and
752    /// rendering-truncation state.
753    #[must_use = "redacted NDJSON text and its status must be handled together"]
754    fn redact_ndjson(&self, bounded: &[u8], truncated: bool) -> ParsedBody {
755        if truncated {
756            return ParsedBody::new(
757                markers::INVALID_OR_TRUNCATED_NDJSON.to_string(),
758                BodyRedactionStatus::Redacted(
759                    BodyRedactionReason::InvalidOrTruncatedNdjson,
760                ),
761                false,
762            );
763        }
764        match json::redact_ndjson(
765            &self.body_field_redactor(),
766            bounded,
767            self.policy.json_depth_budget(),
768            self.policy.unkeyed_json_value_policy(),
769            self.policy.body_budget().max_output_bytes(),
770        ) {
771            Some((output, passed, rendered_truncated)) => ParsedBody::new(
772                output,
773                if passed {
774                    BodyRedactionStatus::PassedThrough
775                } else {
776                    BodyRedactionStatus::Structured
777                },
778                rendered_truncated,
779            ),
780            None => ParsedBody::new(
781                markers::INVALID_NDJSON.to_string(),
782                BodyRedactionStatus::Redacted(
783                    BodyRedactionReason::InvalidNdjson,
784                ),
785                false,
786            ),
787        }
788    }
789
790    /// Redacts a bounded URL-encoded body.
791    ///
792    /// # Parameters
793    ///
794    /// * `bounded` - Bounded form bytes.
795    /// * `truncated` - Whether source bytes follow the prefix.
796    ///
797    /// # Returns
798    ///
799    /// Redacted form text or a fixed invalid marker, status, and complete
800    /// rendering state.
801    #[must_use = "redacted form text and its status must be handled together"]
802    fn redact_body_form(&self, bounded: &[u8], truncated: bool) -> ParsedBody {
803        if truncated {
804            return ParsedBody::new(
805                markers::INVALID_OR_TRUNCATED_FORM.to_string(),
806                BodyRedactionStatus::Redacted(
807                    BodyRedactionReason::InvalidOrTruncatedFormUrlEncoded,
808                ),
809                false,
810            );
811        }
812        if !form::is_valid(bounded) {
813            return ParsedBody::new(
814                markers::INVALID_FORM.to_string(),
815                BodyRedactionStatus::Redacted(
816                    BodyRedactionReason::InvalidFormUrlEncoded,
817                ),
818                false,
819            );
820        }
821        ParsedBody::new(
822            form::redact_bounded(
823                &self.body_field_redactor(),
824                bounded,
825                self.policy.body_budget().max_output_bytes(),
826            ),
827            BodyRedactionStatus::Structured,
828            false,
829        )
830    }
831
832    /// Redacts unsupported, opaque-text, or binary bounded input.
833    ///
834    /// # Parameters
835    ///
836    /// * `bounded` - Bounded fallback bytes.
837    /// * `is_text` - Whether the parsed Content-Type is an opaque text type.
838    ///
839    /// # Returns
840    ///
841    /// A policy-controlled text marker or binary summary, status, and complete
842    /// rendering state.
843    #[must_use = "fallback text and its status must be handled together"]
844    fn redact_fallback(&self, bounded: &[u8], is_text: bool) -> ParsedBody {
845        match std::str::from_utf8(bounded) {
846            Err(_) => ParsedBody::new(
847                format!("<binary {} bytes>", bounded.len()),
848                BodyRedactionStatus::Binary,
849                false,
850            ),
851            Ok(text) if is_text => match self.policy.text_body_policy() {
852                TextBodyPolicy::Redact => ParsedBody::new(
853                    markers::TEXT_BODY.to_string(),
854                    BodyRedactionStatus::Redacted(
855                        BodyRedactionReason::OpaqueText,
856                    ),
857                    false,
858                ),
859                TextBodyPolicy::PassThrough => ParsedBody::new(
860                    text.to_string(),
861                    BodyRedactionStatus::PassedThrough,
862                    false,
863                ),
864            },
865            Ok(_) => ParsedBody::new(
866                markers::UNSUPPORTED_BODY.to_string(),
867                BodyRedactionStatus::Redacted(
868                    BodyRedactionReason::UnsupportedMediaType,
869                ),
870                false,
871            ),
872        }
873    }
874
875    /// Escapes, bounds, and attaches exact source metadata to parser output.
876    ///
877    /// # Parameters
878    ///
879    /// * `parsed` - Unescaped redacted payload, status, and rendering state.
880    /// * `capture` - Original checked source metadata.
881    /// * `captured_len` - Number of bytes actually inspected.
882    /// * `budget_truncated` - Whether the input budget omitted captured bytes.
883    /// * `budget` - Hard output limit.
884    ///
885    /// # Returns
886    ///
887    /// A log-safe bounded body result with exact available metadata.
888    fn finish_body_redaction(
889        parsed: ParsedBody,
890        capture: BodyCapture<'_>,
891        captured_len: usize,
892        budget_truncated: bool,
893        budget: BodyBudget,
894    ) -> BodyRedaction {
895        let (parsed_text, status, rendered_truncated) = parsed.into_parts();
896        let source_truncated = capture.is_source_truncated()
897            || budget_truncated
898            || rendered_truncated;
899        let mut writer =
900            BoundedLogWriter::new(budget.max_output_bytes(), source_truncated);
901        let _ = writer.write_str(&parsed_text);
902        let (text, truncated) = writer.finish();
903        let source_len = capture.total_len();
904        let omitted_len =
905            source_len.map(|total| total.saturating_sub(captured_len));
906        BodyRedaction::new(
907            text,
908            status,
909            captured_len,
910            source_len,
911            omitted_len,
912            truncated,
913        )
914    }
915}
916
917/// Charges one safe text result to the shared session or returns the fixed
918/// diagnostic-limit marker after the event budget is exhausted.
919fn charge_session_output(
920    session: &RedactionSession<'_>,
921    value: LogSafeText<'static>,
922) -> LogSafeText<'static> {
923    match session.charge_output_or_fallback(
924        value.as_str().len(),
925        markers::DIAGNOSTIC_LIMIT_EXCEEDED.len(),
926    ) {
927        OutputCharge::Complete => value,
928        OutputCharge::Fallback => HttpRedactor::diagnostic_limit_exceeded(),
929        OutputCharge::Exhausted => empty_log_safe_text(),
930    }
931}
932
933/// Charges the diagnostic-limit marker itself, returning empty safe text when
934/// a prior eager fragment left insufficient room for the complete marker.
935fn session_diagnostic_limit_exceeded(
936    session: &RedactionSession<'_>,
937) -> LogSafeText<'static> {
938    match session.charge_output_or_fallback(
939        markers::DIAGNOSTIC_LIMIT_EXCEEDED.len(),
940        markers::DIAGNOSTIC_LIMIT_EXCEEDED.len(),
941    ) {
942        OutputCharge::Complete => HttpRedactor::diagnostic_limit_exceeded(),
943        OutputCharge::Fallback | OutputCharge::Exhausted => {
944            empty_log_safe_text()
945        }
946    }
947}
948
949/// Constructs an empty typed safe fragment after cumulative output exhaustion.
950fn empty_log_safe_text() -> LogSafeText<'static> {
951    LogSafeText::from_escaped(Cow::Borrowed(""))
952}
953
954/// Charges a safe header result to the shared session.
955fn charge_header_output(
956    session: &RedactionSession<'_>,
957    value: RedactedHeaders,
958) -> RedactedHeaders {
959    match session.charge_output_or_fallback(
960        value.log_safe_text().as_str().len(),
961        markers::DIAGNOSTIC_LIMIT_EXCEEDED.len(),
962    ) {
963        OutputCharge::Complete => value,
964        OutputCharge::Fallback => {
965            RedactedHeaders::new(HttpRedactor::diagnostic_limit_exceeded())
966        }
967        OutputCharge::Exhausted => RedactedHeaders::new(empty_log_safe_text()),
968    }
969}
970
971/// Charges a safe body result to the shared session.
972fn charge_body_output(
973    session: &RedactionSession<'_>,
974    value: BodyRedaction,
975) -> BodyRedaction {
976    match session.charge_output_or_fallback(
977        value.log_safe_text().as_str().len(),
978        markers::DIAGNOSTIC_LIMIT_EXCEEDED.len(),
979    ) {
980        OutputCharge::Complete => value,
981        OutputCharge::Fallback => session_limited_body(
982            BodyCapture::complete(b""),
983            markers::DIAGNOSTIC_LIMIT_EXCEEDED.to_owned(),
984        ),
985        OutputCharge::Exhausted => {
986            session_limited_body(BodyCapture::complete(b""), String::new())
987        }
988    }
989}
990
991/// Constructs a body result that contains no source bytes after a budget
992/// failure.
993fn session_limited_body(
994    capture: BodyCapture<'_>,
995    text: String,
996) -> BodyRedaction {
997    BodyRedaction::new(
998        text,
999        BodyRedactionStatus::Redacted(
1000            BodyRedactionReason::DiagnosticBudgetExceeded,
1001        ),
1002        0,
1003        capture.total_len(),
1004        capture.total_len(),
1005        true,
1006    )
1007}
1008
1009impl Default for HttpRedactor {
1010    /// Creates a redactor from the current default HTTP policy.
1011    ///
1012    /// # Returns
1013    ///
1014    /// A fail-closed redactor with finite body limits.
1015    fn default() -> Self {
1016        Self::new(RedactionPolicy::default())
1017    }
1018}