Skip to main content

qubit_redact/formats/http/
http_redaction_writer.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//! Mutable HTTP façade over one active redaction transaction.
9
10use http::HeaderMap;
11use http::HeaderValue;
12use url::Url;
13
14use super::BodyCapture;
15use super::admitted_body::AdmittedBody;
16use super::internal::nested_url;
17use super::internal::nested_url::NestedUrl;
18use super::redaction::url_rules;
19use crate::runtime::OperationSink;
20use crate::runtime::TextSession;
21use crate::runtime::runtime_session::RuntimeSession;
22
23/// Feature-gated HTTP operations sharing one mutable diagnostic session.
24pub struct HttpRedactionWriter<'session> {
25    /// Text transaction that owns policy, accounting, and aggregate output.
26    pub(super) session: &'session mut TextSession,
27}
28
29impl<'session> HttpRedactionWriter<'session> {
30    /// Creates an HTTP facade borrowing a parent session.
31    pub(crate) const fn new(session: &'session mut TextSession) -> Self {
32        Self { session }
33    }
34
35    /// Redacts a URL string into the parent session's aggregate output.
36    pub fn url(&mut self, value: &str) -> &mut Self {
37        if self.session.skip_aggregate_for_exhausted_output() || !self.session.admit_format_node(1) {
38            return self;
39        }
40        let input_was_empty = value.is_empty();
41        let value = self.session.admit_input_prefix(value);
42        if value.is_empty() && !input_was_empty {
43            return self;
44        }
45        if !admit_url_structure(self.session, value) {
46            self.session.append_rendered_operation(
47                OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
48            );
49            return self;
50        }
51        let result = self.redact_url_str_direct(value);
52        self.session.append_rendered_operation(result.into_operation());
53        self
54    }
55
56    /// Redacts headers into the parent session's aggregate output.
57    pub fn headers(&mut self, headers: &HeaderMap) -> &mut Self {
58        let Some(headers) = collect_admitted_headers(self.session, headers) else {
59            return self;
60        };
61        let result = self.redact_headers_direct(&headers);
62        self.session.append_rendered_operation(result.into_operation());
63        self
64    }
65}
66
67/// Charges URL query traversal before rendering.
68pub(crate) fn admit_url_structure(session: &mut dyn RuntimeSession, text: &str) -> bool {
69    let Ok(url) = Url::parse(text) else {
70        return true;
71    };
72    admit_url_structure_at_depth(session, &url, 1)
73}
74
75/// Charges recursively nested URL query structure.
76fn admit_url_structure_at_depth(session: &mut dyn RuntimeSession, url: &Url, url_depth: usize) -> bool {
77    let Some(query) = url.query() else {
78        return true;
79    };
80    if !super::internal::form::is_valid(query.as_bytes()) {
81        return true;
82    }
83    for (_, value) in url.query_pairs() {
84        if !session.admit_format_collection_item() || !session.admit_format_node(url_depth.saturating_add(1)) {
85            return false;
86        }
87        match nested_url::detect(value.as_ref()) {
88            NestedUrl::Parsed(nested) if url_depth < url_rules::MAX_NESTED_URL_DEPTH => {
89                if !session.admit_format_node(url_depth.saturating_add(1))
90                    || !admit_url_structure_at_depth(session, &nested, url_depth.saturating_add(1))
91                {
92                    return false;
93                }
94            }
95            NestedUrl::NotUrl | NestedUrl::Parsed(_) | NestedUrl::Invalid | NestedUrl::LimitExceeded => {}
96        }
97    }
98    true
99}
100
101/// Rebuilds only the header prefix admitted by the transaction.
102pub(crate) fn collect_admitted_headers(session: &mut dyn RuntimeSession, headers: &HeaderMap) -> Option<HeaderMap> {
103    if session.skip_aggregate_for_exhausted_output() || !session.admit_format_node(1) {
104        return None;
105    }
106    let mut admitted = HeaderMap::new();
107    for (name, value) in headers {
108        if !session.admit_format_collection_item()
109            || !session.admit_format_node(2)
110            || !session.admit_input(name.as_str().len().saturating_add(value.as_bytes().len()))
111        {
112            return None;
113        }
114        admitted.append(name.clone(), value.clone());
115    }
116    Some(admitted)
117}
118
119impl<'session> HttpRedactionWriter<'session> {
120    /// Parses and redacts one URL string.
121    #[must_use]
122    fn redact_url_str_direct(&mut self, text: &str) -> super::redaction::HttpRendered {
123        super::redaction::redact_url_str_with_policy(self.session.policy(), text, self.session.remaining_output_bytes())
124    }
125
126    /// Redacts all HTTP headers.
127    #[must_use]
128    fn redact_headers_direct(&mut self, headers: &HeaderMap) -> super::redaction::HttpRendered {
129        super::redaction::redact_headers_with_policy(
130            self.session.policy(),
131            headers,
132            self.session.remaining_output_bytes(),
133        )
134    }
135
136    /// Redacts a captured HTTP body into the parent session's aggregate output.
137    ///
138    /// Body and content-type byte lengths are offered to the shared budget
139    /// before the body renderer inspects their contents. Rejected input emits
140    /// a non-empty diagnostic fallback when it fits; exhausted output returns
141    /// empty text and does not invoke the renderer. Successful admission
142    /// commits only the bounded output and closes the session when the body or
143    /// session budget omits content.
144    ///
145    /// # Parameters
146    ///
147    /// * `capture` - Captured body bytes and optional source-length metadata.
148    /// * `content_type` - Parsed header value used to select body handling.
149    ///
150    /// # Returns
151    ///
152    /// A bounded body result with completion and capture metadata.
153    #[must_use]
154    pub fn body(&mut self, capture: BodyCapture<'_>, content_type: Option<&HeaderValue>) -> &mut Self {
155        if self.session.skip_aggregate_for_exhausted_output()
156            || !admit_body_input(self.session, capture, content_type.map(|v| v.as_bytes().len()))
157        {
158            return self;
159        }
160        let Some(admitted) = admit_body_structure(self.session, capture, content_type.map(|value| value.as_bytes()))
161        else {
162            self.session.append_rendered_operation(
163                OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
164            );
165            return self;
166        };
167        let remaining = self.session.remaining_output_bytes();
168        let result = super::redaction::redact_admitted_body_with_policy(
169            self.session.policy(),
170            capture,
171            content_type,
172            admitted,
173            remaining,
174        );
175        self.session.append_rendered_operation(result.into_operation());
176        self
177    }
178
179    /// Redacts a captured HTTP body with text Content-Type.
180    ///
181    /// Body and content-type byte lengths are offered to the shared budget
182    /// before the body renderer inspects their contents. Rejected input emits
183    /// a non-empty diagnostic fallback when it fits; exhausted output returns
184    /// empty text and does not invoke the renderer. Successful admission
185    /// commits only the bounded output and closes the session when the body or
186    /// session budget omits content.
187    ///
188    /// # Parameters
189    ///
190    /// * `capture` - Captured body bytes and optional source-length metadata.
191    /// * `content_type` - Text media type used to select body handling.
192    ///
193    /// # Returns
194    ///
195    /// A bounded body result with completion and capture metadata.
196    #[must_use]
197    pub fn body_with_content_type_text(&mut self, capture: BodyCapture<'_>, content_type: Option<&str>) -> &mut Self {
198        if self.session.skip_aggregate_for_exhausted_output()
199            || !admit_body_input(self.session, capture, content_type.map(str::len))
200        {
201            return self;
202        }
203        let Some(admitted) = admit_body_structure(self.session, capture, content_type.map(str::as_bytes)) else {
204            self.session.append_rendered_operation(
205                OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
206            );
207            return self;
208        };
209        let remaining = self.session.remaining_output_bytes();
210        let result = super::redaction::redact_admitted_body_with_content_type_text_with_policy(
211            self.session.policy(),
212            capture,
213            content_type,
214            admitted,
215            remaining,
216        );
217        self.session.append_rendered_operation(result.into_operation());
218        self
219    }
220}
221
222/// Charges body structure before the HTTP renderer parses it.
223pub(crate) fn admit_body_structure(
224    session: &mut dyn RuntimeSession,
225    capture: BodyCapture<'_>,
226    content_type: Option<&[u8]>,
227) -> Option<AdmittedBody> {
228    if session.policy().is_disabled() {
229        return session.admit_format_node(1).then_some(AdmittedBody::Other);
230    }
231    let has_content_type = content_type.is_some();
232    let content_type = content_type
233        .and_then(|value| std::str::from_utf8(value).ok())
234        .and_then(super::internal::content_type::parse);
235    let inferred_json = !has_content_type
236        && matches!(
237            capture.bytes().iter().copied().find(|byte| !byte.is_ascii_whitespace()),
238            Some(b'{') | Some(b'[')
239        );
240    if capture.is_source_truncated()
241        && (matches!(
242            &content_type,
243            Some(super::internal::content_type::ContentType::Json)
244                | Some(super::internal::content_type::ContentType::Ndjson)
245        ) || inferred_json)
246    {
247        // A captured prefix is intentionally incomplete JSON. Admit only
248        // the enclosing format node and let the renderer publish the
249        // invalid/truncated provenance without attempting a partial parse.
250        return session.admit_format_node(1).then_some(AdmittedBody::Other);
251    }
252    if matches!(&content_type, Some(super::internal::content_type::ContentType::Json)) || inferred_json {
253        let Ok(text) = std::str::from_utf8(capture.bytes()) else {
254            return session.admit_format_node(1).then_some(AdmittedBody::InvalidJson);
255        };
256        return match crate::formats::json::admit_json_text_value(session, text) {
257            Ok(value) => Some(AdmittedBody::Json(value)),
258            Err(crate::formats::json::JsonAdmissionError::Invalid) => Some(AdmittedBody::InvalidJson),
259            Err(crate::formats::json::JsonAdmissionError::Limit) => None,
260        };
261    }
262    if matches!(&content_type, Some(super::internal::content_type::ContentType::Ndjson)) {
263        let Ok(text) = std::str::from_utf8(capture.bytes()) else {
264            return session.admit_format_node(1).then_some(AdmittedBody::InvalidNdjson);
265        };
266        let mut lines = Vec::new();
267        let mut admitted_any = false;
268        for line in text.lines() {
269            if line.trim().is_empty() {
270                lines.push(None);
271                continue;
272            }
273            admitted_any = true;
274            match crate::formats::json::admit_json_text_value(session, line) {
275                Ok(value) => lines.push(Some(value)),
276                Err(crate::formats::json::JsonAdmissionError::Invalid) => {
277                    return Some(AdmittedBody::InvalidNdjson);
278                }
279                Err(crate::formats::json::JsonAdmissionError::Limit) => return None,
280            }
281        }
282        if !admitted_any && !session.admit_format_node(1) {
283            return None;
284        }
285        return Some(AdmittedBody::Ndjson {
286            lines,
287            trailing_newline: text.ends_with('\n'),
288        });
289    }
290    if !session.admit_format_node(1) {
291        return None;
292    }
293    let admitted = match content_type {
294        Some(super::internal::content_type::ContentType::Form) => {
295            super::internal::multipart::admit_form_fields(session, capture.bytes(), 2)
296        }
297        Some(super::internal::content_type::ContentType::Multipart {
298            boundary: Some(boundary),
299            require_form_data,
300        }) => {
301            return super::internal::multipart::admit_structure(session, &boundary, require_form_data, capture.bytes())
302                .map(AdmittedBody::Multipart);
303        }
304        Some(super::internal::content_type::ContentType::Multipart { boundary: None, .. })
305        | Some(super::internal::content_type::ContentType::Text)
306        | Some(super::internal::content_type::ContentType::Other)
307        | None => true,
308        Some(super::internal::content_type::ContentType::Json)
309        | Some(super::internal::content_type::ContentType::Ndjson) => true,
310    };
311    admitted.then_some(AdmittedBody::Other)
312}
313
314/// Counts bytes presented by a body operation before parser dispatch.
315pub(crate) fn admit_body_input(
316    session: &mut dyn RuntimeSession,
317    capture: BodyCapture<'_>,
318    content_type_len: Option<usize>,
319) -> bool {
320    let content_type_len = content_type_len.unwrap_or(0);
321    let inspectable = capture.bytes().len().saturating_add(content_type_len);
322    let total = capture
323        .total_len()
324        .map(|length| length.saturating_add(content_type_len));
325    session.admit_source_input(total, inspectable)
326}
327
328#[cfg(test)]
329mod tests {
330    use http::HeaderValue;
331
332    use super::BodyCapture;
333    use crate::Redactor;
334    use crate::formats::json::parse_counter::json_parse_count;
335    use crate::formats::json::parse_counter::reset_json_parse_count;
336
337    /// Verifies HTTP JSON admission and rendering share one parsed tree.
338    #[test]
339    fn enabled_http_json_body_is_parsed_exactly_once() {
340        reset_json_parse_count();
341
342        let output = Redactor::standard().redact_http_body(
343            BodyCapture::complete(br#"{"token":"raw-secret"}"#),
344            Some(&HeaderValue::from_static("application/json")),
345        );
346
347        assert_eq!(json_parse_count(), 1);
348        assert!(!output.text().as_str().contains("raw-secret"));
349    }
350
351    /// Verifies each non-empty NDJSON line is parsed exactly once.
352    #[test]
353    fn enabled_http_ndjson_lines_are_parsed_exactly_once() {
354        reset_json_parse_count();
355
356        let output = Redactor::standard().redact_http_body(
357            BodyCapture::complete(b"{\"token\":\"one\"}\n{\"token\":\"two\"}\n"),
358            Some(&HeaderValue::from_static("application/x-ndjson")),
359        );
360
361        assert_eq!(json_parse_count(), 2);
362        assert!(!output.text().as_str().contains("one"));
363        assert!(!output.text().as_str().contains("two"));
364    }
365
366    /// Verifies the admitted NDJSON model retains empty source lines.
367    #[test]
368    fn enabled_http_ndjson_preserves_empty_lines() {
369        let output = Redactor::standard().redact_http_body(
370            BodyCapture::complete(b"{\"name\":\"one\"}\n\n{\"name\":\"two\"}\n"),
371            Some(&HeaderValue::from_static("application/x-ndjson")),
372        );
373
374        assert_eq!(output.text().as_str(), "{\"name\":\"one\"}\\n\\n{\"name\":\"two\"}\\n",);
375    }
376}