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::internal::AdmittedBody;
16use super::internal::http_policy_executor::url_rules;
17use super::internal::nested_url;
18use super::internal::nested_url::NestedUrl;
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.
24///
25/// # Type Parameters
26///
27/// * `'session` - Borrow of the parent composer's unpublished transaction.
28///
29/// # Examples
30///
31/// ```
32/// use qubit_redact::Redactor;
33///
34/// let output = Redactor::standard().text_composer().http(|http| {
35///     http.url("https://example.test/?password=raw-secret");
36/// }).finish();
37/// assert!(!output.text().as_str().contains("raw-secret"));
38/// ```
39pub struct HttpRedactionWriter<'session> {
40    /// Text transaction that owns policy, accounting, and aggregate output.
41    pub(super) session: &'session mut TextSession,
42}
43
44impl<'session> HttpRedactionWriter<'session> {
45    /// Creates an HTTP facade borrowing a parent session.
46    ///
47    /// # Parameters
48    ///
49    /// * `session` - Parent transaction receiving HTTP diagnostic operations.
50    ///
51    /// # Returns
52    ///
53    /// A writer borrowing the existing policy, accounting, and output buffer.
54    #[must_use]
55    #[inline(always)]
56    pub(crate) const fn new(session: &'session mut TextSession) -> Self {
57        Self { session }
58    }
59
60    /// Redacts a URL string into the parent session's aggregate output.
61    ///
62    /// # Parameters
63    ///
64    /// * `value` - URL text whose root, input bytes, and query structure must
65    ///   pass shared admission before rendering.
66    ///
67    /// # Returns
68    ///
69    /// This writer after recording safe output and diagnostic facts.
70    pub fn url(&mut self, value: &str) -> &mut Self {
71        if self.session.skip_aggregate_for_exhausted_output() {
72            return self;
73        }
74        if !self.session.admit_input(value.len()) {
75            self.session.append_rendered_operation(
76                OperationSink::truncated("<truncated>", crate::RedactionReason::InputLimitReached).finish(),
77            );
78            return self;
79        }
80        if !self.session.admit_format_node(1) {
81            self.session.append_rendered_operation(
82                OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
83            );
84            return self;
85        }
86        if !admit_url_structure(self.session, value) {
87            self.session.append_rendered_operation(
88                OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
89            );
90            return self;
91        }
92        let result = self.redact_url_str_direct(value);
93        self.session.append_rendered_operation(result.into_operation());
94        self
95    }
96
97    /// Redacts headers into the parent session's aggregate output.
98    ///
99    /// # Parameters
100    ///
101    /// * `headers` - Borrowed headers, including native sensitive-value flags.
102    ///   The entire collection must pass shared admission before rendering.
103    ///
104    /// # Returns
105    ///
106    /// This writer after appending the admitted collection or recording why
107    /// it could not be rendered.
108    pub fn headers(&mut self, headers: &HeaderMap) -> &mut Self {
109        let Some(headers) = collect_admitted_headers(self.session, headers) else {
110            return self;
111        };
112        let result = self.redact_headers_direct(&headers);
113        self.session.append_rendered_operation(result.into_operation());
114        self
115    }
116}
117
118/// Charges URL query traversal before rendering.
119pub(crate) fn admit_url_structure(session: &mut dyn RuntimeSession, text: &str) -> bool {
120    let Ok(url) = Url::parse(text) else {
121        return true;
122    };
123    admit_url_structure_at_depth(session, &url, 1)
124}
125
126/// Charges recursively nested URL query structure.
127fn admit_url_structure_at_depth(session: &mut dyn RuntimeSession, url: &Url, url_depth: usize) -> bool {
128    let Some(query) = url.query() else {
129        return true;
130    };
131    if !super::internal::form::is_valid(query.as_bytes()) {
132        return true;
133    }
134    for (_, value) in url.query_pairs() {
135        if !session.admit_format_collection_item() || !session.admit_format_node(url_depth.saturating_add(1)) {
136            return false;
137        }
138        match nested_url::detect(value.as_ref()) {
139            NestedUrl::Parsed(nested) if url_depth < url_rules::MAX_NESTED_URL_DEPTH => {
140                if !session.admit_format_node(url_depth.saturating_add(1))
141                    || !admit_url_structure_at_depth(session, &nested, url_depth.saturating_add(1))
142                {
143                    return false;
144                }
145            }
146            NestedUrl::NotUrl | NestedUrl::Parsed(_) | NestedUrl::Invalid | NestedUrl::LimitExceeded => {}
147        }
148    }
149    true
150}
151
152/// Returns `Some` with all headers after the transaction admits them.
153///
154/// Returns `None` if output is closed or any header fails admission; no
155/// partially admitted header collection is returned.
156pub(crate) fn collect_admitted_headers(session: &mut dyn RuntimeSession, headers: &HeaderMap) -> Option<HeaderMap> {
157    if session.skip_aggregate_for_exhausted_output() || !session.admit_format_node(1) {
158        return None;
159    }
160    let mut admitted = HeaderMap::new();
161    for (name, value) in headers {
162        if !session.admit_format_collection_item()
163            || !session.admit_format_node(2)
164            || !session.admit_input(name.as_str().len().saturating_add(value.as_bytes().len()))
165        {
166            return None;
167        }
168        admitted.append(name.clone(), value.clone());
169    }
170    Some(admitted)
171}
172
173impl<'session> HttpRedactionWriter<'session> {
174    /// Redacts a captured HTTP body into the parent session's aggregate output.
175    ///
176    /// Body and content-type byte lengths are offered to the shared budget
177    /// before the body renderer inspects their contents. Rejected input emits
178    /// a non-empty diagnostic fallback when it fits; exhausted output skips
179    /// the renderer. Successful admission appends bounded
180    /// output. Actual output rejection closes later operations; source
181    /// truncation alone leaves any remaining output allowance usable.
182    ///
183    /// # Parameters
184    ///
185    /// * `capture` - Captured body bytes and optional source-length metadata.
186    /// * `content_type` - Parsed header value used to select body handling, or
187    ///   `None` to use the format's inference and fallback rules.
188    ///
189    /// # Returns
190    ///
191    /// This HTTP writer for further operations in the same transaction.
192    pub fn body(&mut self, capture: BodyCapture<'_>, content_type: Option<&HeaderValue>) -> &mut Self {
193        if self.session.skip_aggregate_for_exhausted_output()
194            || !admit_body_input(self.session, capture, content_type.map(|v| v.as_bytes().len()))
195        {
196            return self;
197        }
198        let Some(admitted) = admit_body_structure(self.session, capture, content_type.map(|value| value.as_bytes()))
199        else {
200            self.session.append_rendered_operation(
201                OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
202            );
203            return self;
204        };
205        let remaining = self.session.remaining_output_bytes();
206        let result = super::internal::http_policy_executor::redact_admitted_body_with_policy(
207            self.session.policy(),
208            capture,
209            content_type,
210            admitted,
211            remaining,
212        );
213        self.session.append_rendered_operation(result.into_operation());
214        self
215    }
216
217    /// Redacts a captured HTTP body with text Content-Type.
218    ///
219    /// Body and content-type byte lengths are offered to the shared budget
220    /// before the body renderer inspects their contents. Rejected input emits
221    /// a non-empty diagnostic fallback when it fits; exhausted output skips
222    /// the renderer. Successful admission appends bounded
223    /// output. Actual output rejection closes later operations; source
224    /// truncation alone leaves any remaining output allowance usable.
225    ///
226    /// # Parameters
227    ///
228    /// * `capture` - Captured body bytes and optional source-length metadata.
229    /// * `content_type` - Text media type used to select body handling, or
230    ///   `None` to use the format's inference and fallback rules.
231    ///
232    /// # Returns
233    ///
234    /// This HTTP writer for further operations in the same transaction.
235    pub fn body_with_content_type_text(&mut self, capture: BodyCapture<'_>, content_type: Option<&str>) -> &mut Self {
236        if self.session.skip_aggregate_for_exhausted_output()
237            || !admit_body_input(self.session, capture, content_type.map(str::len))
238        {
239            return self;
240        }
241        let Some(admitted) = admit_body_structure(self.session, capture, content_type.map(str::as_bytes)) else {
242            self.session.append_rendered_operation(
243                OperationSink::truncated("<truncated>", crate::RedactionReason::TraversalLimitReached).finish(),
244            );
245            return self;
246        };
247        let remaining = self.session.remaining_output_bytes();
248        let result = super::internal::http_policy_executor::redact_admitted_body_with_content_type_text_with_policy(
249            self.session.policy(),
250            capture,
251            content_type,
252            admitted,
253            remaining,
254        );
255        self.session.append_rendered_operation(result.into_operation());
256        self
257    }
258
259    /// Parses and redacts an already admitted URL string.
260    ///
261    /// # Parameters
262    ///
263    /// * `text` - URL text admitted by the parent transaction.
264    ///
265    /// # Returns
266    ///
267    /// Unpublished HTTP output bounded by the parent's remaining bytes.
268    #[must_use]
269    #[inline(always)]
270    fn redact_url_str_direct(&mut self, text: &str) -> super::internal::HttpRendered {
271        super::internal::http_policy_executor::redact_url_str_with_policy(
272            self.session.policy(),
273            text,
274            self.session.remaining_output_bytes(),
275        )
276    }
277
278    /// Redacts an already admitted HTTP header collection.
279    ///
280    /// # Parameters
281    ///
282    /// * `headers` - Complete admitted header collection retaining sensitive
283    ///   flags.
284    ///
285    /// # Returns
286    ///
287    /// Unpublished HTTP output bounded by the parent's remaining bytes.
288    #[must_use]
289    #[inline(always)]
290    fn redact_headers_direct(&mut self, headers: &HeaderMap) -> super::internal::HttpRendered {
291        super::internal::http_policy_executor::redact_headers_with_policy(
292            self.session.policy(),
293            headers,
294            self.session.remaining_output_bytes(),
295        )
296    }
297}
298
299/// Parses and admits body structure once for reuse by the HTTP renderer.
300///
301/// Returns `Some` with retained structure or a syntax-failure classification.
302/// Returns `None` when the shared structural budget rejects the body.
303pub(crate) fn admit_body_structure(
304    session: &mut dyn RuntimeSession,
305    capture: BodyCapture<'_>,
306    content_type: Option<&[u8]>,
307) -> Option<AdmittedBody> {
308    if session.policy().is_disabled() {
309        return session.admit_format_node(1).then_some(AdmittedBody::Other);
310    }
311    let has_content_type = content_type.is_some();
312    let content_type = content_type
313        .and_then(|value| std::str::from_utf8(value).ok())
314        .and_then(super::internal::content_type::parse);
315    let inferred_json = !has_content_type
316        && matches!(
317            capture.bytes().iter().copied().find(|byte| !byte.is_ascii_whitespace()),
318            Some(b'{') | Some(b'[')
319        );
320    if capture.is_source_truncated()
321        && (matches!(
322            &content_type,
323            Some(super::internal::content_type::ContentType::Json)
324                | Some(super::internal::content_type::ContentType::Ndjson)
325        ) || inferred_json)
326    {
327        // A captured prefix is intentionally incomplete JSON. Admit only
328        // the enclosing format node and let the renderer publish the
329        // invalid/truncated provenance without attempting a partial parse.
330        return session.admit_format_node(1).then_some(AdmittedBody::Other);
331    }
332    if matches!(&content_type, Some(super::internal::content_type::ContentType::Json)) || inferred_json {
333        let Ok(text) = std::str::from_utf8(capture.bytes()) else {
334            return session.admit_format_node(1).then_some(AdmittedBody::InvalidJson);
335        };
336        return match crate::formats::json::admit_json_text_value(session, text) {
337            Ok(value) => Some(AdmittedBody::Json(value)),
338            Err(crate::formats::json::JsonAdmissionError::Invalid) => Some(AdmittedBody::InvalidJson),
339            Err(crate::formats::json::JsonAdmissionError::Limit) => None,
340        };
341    }
342    if matches!(&content_type, Some(super::internal::content_type::ContentType::Ndjson)) {
343        let Ok(text) = std::str::from_utf8(capture.bytes()) else {
344            return session.admit_format_node(1).then_some(AdmittedBody::InvalidNdjson);
345        };
346        let mut lines = Vec::new();
347        let mut admitted_any = false;
348        for line in text.lines() {
349            if line.trim().is_empty() {
350                lines.push(None);
351                continue;
352            }
353            admitted_any = true;
354            match crate::formats::json::admit_json_text_value(session, line) {
355                Ok(value) => lines.push(Some(value)),
356                Err(crate::formats::json::JsonAdmissionError::Invalid) => {
357                    return Some(AdmittedBody::InvalidNdjson);
358                }
359                Err(crate::formats::json::JsonAdmissionError::Limit) => {
360                    return None;
361                }
362            }
363        }
364        if !admitted_any && !session.admit_format_node(1) {
365            return None;
366        }
367        return Some(AdmittedBody::Ndjson {
368            lines,
369            trailing_newline: text.ends_with('\n'),
370        });
371    }
372    if !session.admit_format_node(1) {
373        return None;
374    }
375    let admitted = match content_type {
376        Some(super::internal::content_type::ContentType::Form) => {
377            super::internal::multipart::admit_form_fields(session, capture.bytes(), 2)
378        }
379        Some(super::internal::content_type::ContentType::Multipart {
380            boundary: Some(boundary),
381            require_form_data,
382        }) => {
383            return super::internal::multipart::admit_structure(session, &boundary, require_form_data, capture.bytes())
384                .map(AdmittedBody::Multipart);
385        }
386        Some(super::internal::content_type::ContentType::Multipart { boundary: None, .. })
387        | Some(super::internal::content_type::ContentType::Text)
388        | Some(super::internal::content_type::ContentType::Other)
389        | None => true,
390        Some(super::internal::content_type::ContentType::Json)
391        | Some(super::internal::content_type::ContentType::Ndjson) => true,
392    };
393    admitted.then_some(AdmittedBody::Other)
394}
395
396/// Counts bytes presented by a body operation before parser dispatch.
397pub(crate) fn admit_body_input(
398    session: &mut dyn RuntimeSession,
399    capture: BodyCapture<'_>,
400    content_type_len: Option<usize>,
401) -> bool {
402    let content_type_len = content_type_len.unwrap_or(0);
403    let inspectable = capture.bytes().len().saturating_add(content_type_len);
404    let total = capture
405        .total_len()
406        .map(|length| length.saturating_add(content_type_len));
407    session.admit_source_input(total, inspectable)
408}