qubit_redact/formats/http/
http_redaction_writer.rs1use 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
23pub struct HttpRedactionWriter<'session> {
40 pub(super) session: &'session mut TextSession,
42}
43
44impl<'session> HttpRedactionWriter<'session> {
45 #[must_use]
55 #[inline(always)]
56 pub(crate) const fn new(session: &'session mut TextSession) -> Self {
57 Self { session }
58 }
59
60 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 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
118pub(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
126fn 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
152pub(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 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 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 #[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 #[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
299pub(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 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
396pub(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}