Skip to main content

pixellint_core/
http.rs

1//! Complete HTTP captures keep endpoint, transport and body contracts together.
2//! Raw text and explicit binary wire bytes preserve capture evidence.
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value};
8
9use crate::{
10    ArtifactKind, CoreRulePack, DIRECTORY_ID, Engine, EngineError, PreparedArtifact, RuleSource,
11    Severity, ValidationOptions, ValidationReport, ValidationRequest, ValidationSummary,
12    ValidatorPlugin, Violation, detect_macro_spans,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(deny_unknown_fields)]
17pub struct HttpHeader {
18    pub name: String,
19    pub value: String,
20}
21
22/// A list preserves repeated field lines. Object keys remain case insensitive.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(untagged)]
25pub enum HttpHeaders {
26    Object(BTreeMap<String, String>),
27    List(Vec<HttpHeader>),
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct HttpRequest {
33    pub url: String,
34    pub method: String,
35    pub headers: HttpHeaders,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub body: Option<String>,
38}
39
40/// Explicit captured binary wire bytes. Serialize this envelope with artifact
41/// kind `request`; [`HttpRequest`] retains its existing source compatibility.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct BinaryHttpRequest {
45    pub url: String,
46    pub method: String,
47    pub headers: HttpHeaders,
48    pub body_base64: String,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub capture: Option<HttpCaptureContext>,
51}
52
53/// Availability declarations describe omitted capture data, not vendor waivers.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
55#[serde(deny_unknown_fields)]
56pub struct HttpCaptureContext {
57    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
58    pub headers_unavailable: bool,
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub unavailable_headers: Vec<String>,
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub redacted_headers: Vec<String>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub body: Option<HttpBodyAvailability>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum HttpBodyAvailability {
70    Available,
71    Absent,
72    Unavailable,
73    Redacted,
74}
75
76/// Serialize this additive envelope when capture fields are incomplete.
77/// Plain [`HttpRequest`] serialization retains its complete-capture semantics.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct CapturedHttpRequest {
80    #[serde(flatten)]
81    pub request: HttpRequest,
82    pub capture: HttpCaptureContext,
83}
84
85impl HttpCaptureContext {
86    pub(crate) fn body_unavailable(&self) -> bool {
87        matches!(
88            self.body,
89            Some(HttpBodyAvailability::Unavailable | HttpBodyAvailability::Redacted)
90        )
91    }
92
93    fn redacted_header(&self, name: &str) -> bool {
94        self.redacted_headers
95            .iter()
96            .any(|entry| entry.eq_ignore_ascii_case(name))
97    }
98
99    fn header_unknown(&self, name: &str, present: bool) -> bool {
100        self.redacted_header(name)
101            || (!present
102                && (self.headers_unavailable
103                    || self
104                        .unavailable_headers
105                        .iter()
106                        .any(|entry| entry.eq_ignore_ascii_case(name))))
107    }
108}
109
110struct ParsedCapture {
111    request: HttpRequest,
112    context: HttpCaptureContext,
113    binary_body: Option<String>,
114}
115
116fn parse_capture(value: Value) -> Option<ParsedCapture> {
117    let Value::Object(mut object) = value else {
118        return None;
119    };
120    let context = match object.remove("capture") {
121        Some(value) => serde_json::from_value::<HttpCaptureContext>(value).ok()?,
122        None => HttpCaptureContext::default(),
123    };
124    let binary_body = match object.remove("body_base64") {
125        Some(Value::String(value)) if !object.contains_key("body") => Some(value),
126        Some(_) => return None,
127        None => None,
128    };
129    let request = serde_json::from_value::<HttpRequest>(Value::Object(object)).ok()?;
130    let has_body = request.body.is_some() || binary_body.is_some();
131    if matches!(context.body, Some(HttpBodyAvailability::Available)) && !has_body
132        || matches!(context.body, Some(HttpBodyAvailability::Absent)) && has_body
133    {
134        return None;
135    }
136    let mut names = BTreeSet::new();
137    for name in context
138        .unavailable_headers
139        .iter()
140        .chain(&context.redacted_headers)
141    {
142        if !token(name) || !names.insert(name.to_ascii_lowercase()) {
143            return None;
144        }
145    }
146    Some(ParsedCapture {
147        request,
148        context,
149        binary_body,
150    })
151}
152
153struct DecodedRequest {
154    normalized: String,
155    json_body: Option<String>,
156    form: bool,
157    multipart_fields: Option<Vec<crate::manifest::RawParam<'static>>>,
158    unavailable_form: bool,
159    entity_text: Option<String>,
160    entity_bytes: usize,
161    unsupported: bool,
162    violations: Vec<Violation>,
163}
164
165impl Engine {
166    pub(crate) fn validate_http_json_at(
167        &self,
168        request: &ValidationRequest,
169        options: &ValidationOptions,
170        clock: i64,
171    ) -> Result<ValidationSummary, EngineError> {
172        self.ensure_known_rulepacks(&options.only_rulepacks)?;
173        self.ensure_known_rulepacks(&options.except_rulepacks)?;
174        // Reject duplicate capture keys before serde can overwrite headers.
175        let capture = crate::json::JsonDocument::parse(&request.artifact)
176            .ok()
177            .filter(|document| {
178                let unique = |path: &str| {
179                    let names = document.members(path);
180                    names
181                        .iter()
182                        .map(|(name, _)| name)
183                        .collect::<BTreeSet<_>>()
184                        .len()
185                        == names.len()
186                };
187                unique("")
188                    && unique("capture")
189                    && unique("headers")
190                    && document.expand("headers[]").iter().all(|path| unique(path))
191            })
192            .and_then(|_| serde_json::from_str::<Value>(&request.artifact).ok())
193            .and_then(parse_capture);
194        let Some(ParsedCapture {
195            request: capture,
196            context,
197            binary_body,
198        }) = capture
199        else {
200            return Ok(ValidationSummary::from_evaluated(
201                vec![core_report(vec![request_violation(
202                    "invalid_envelope",
203                    None,
204                    "A request capture needs string url and method, headers as an object or name/value list, and at most one raw string body or string body_base64. Availability declarations must agree with body presence.",
205                    Severity::Error,
206                )])],
207                Vec::new(),
208            ));
209        };
210        let url_request = ValidationRequest {
211            artifact_kind: ArtifactKind::Url,
212            artifact: capture.url.clone(),
213            claimed_vendor: request.claimed_vendor.clone(),
214            expansion_state: request.expansion_state,
215        };
216        // Endpoint probing can populate parameter caches. Keep it separate
217        // from the request prepared after decoded form fields are available.
218        let endpoint = PreparedArtifact::from_request_at(&url_request, clock);
219        let headerless_form = capture_allows_headerless_form(&capture, &context)
220            && self.candidate_ids(&endpoint).into_iter().any(|id| {
221                !options
222                    .except_rulepacks
223                    .iter()
224                    .any(|excluded| excluded == id)
225                    && (options.only_rulepacks.is_empty()
226                        || options.only_rulepacks.iter().any(|selected| selected == id))
227                    && self
228                        .plugins
229                        .get(id)
230                        .is_some_and(|entry| entry.plugin.uses_headerless_form_body(&endpoint))
231            });
232        let decoded = decode_request_with_form_hint(
233            &capture,
234            &context,
235            binary_body.as_deref(),
236            clock,
237            headerless_form,
238        );
239        let mut prepared = PreparedArtifact::from_request_at(&url_request, clock);
240        prepared.mark_complete_http_request();
241        if decoded.form {
242            prepared.set_form_body(decoded.entity_text.as_deref().unwrap_or_default());
243        }
244        if let Some(fields) = &decoded.multipart_fields {
245            prepared.set_form_fields(fields.clone());
246        }
247        if decoded.unavailable_form {
248            prepared.mark_unavailable_form_body();
249        }
250        let body_request = decoded.json_body.as_ref().map(|body| ValidationRequest {
251            artifact_kind: ArtifactKind::JsonPayload,
252            artifact: body.clone(),
253            claimed_vendor: request.claimed_vendor.clone(),
254            expansion_state: request.expansion_state,
255        });
256        let body = body_request
257            .as_ref()
258            .map(|body| PreparedArtifact::from_request_at(body, clock));
259        let excluded: BTreeSet<_> = options
260            .except_rulepacks
261            .iter()
262            .map(String::as_str)
263            .collect();
264        let explicit = !options.only_rulepacks.is_empty();
265        let plugins: Vec<&crate::PluginEntry> = if explicit {
266            options
267                .only_rulepacks
268                .iter()
269                .filter(|id| id.as_str() != DIRECTORY_ID && !excluded.contains(id.as_str()))
270                .filter_map(|id| self.plugins.get(id))
271                .collect()
272        } else {
273            self.candidate_ids(&prepared)
274                .into_iter()
275                .filter(|id| !excluded.contains(id))
276                .filter_map(|id| self.plugins.get(id))
277                .filter(|entry| entry.plugin.supports_http(&prepared))
278                .collect()
279        };
280        if plugins.is_empty()
281            && !(explicit && options.only_rulepacks.iter().all(|id| id == DIRECTORY_ID))
282        {
283            return Err(if !explicit && excluded.is_empty() {
284                EngineError::NoMatchingPlugin
285            } else {
286                EngineError::NoRulepacksSelected
287            });
288        }
289        for entry in &plugins {
290            entry.plugin.prepare_vendor_context(&mut prepared);
291        }
292        let vendor_rulepacks = plugins
293            .iter()
294            .filter(|entry| {
295                entry.plugin.metadata().id != "core"
296                    && entry.plugin.metadata().id != DIRECTORY_ID
297                    && entry.plugin.supports_http(&prepared)
298            })
299            .map(|entry| entry.plugin.metadata().id.clone())
300            .collect::<Vec<_>>();
301        let mut sdk_violations = Vec::new();
302        let content_types: Vec<_> = header_lines(&capture.headers)
303            .into_iter()
304            .filter(|(name, _)| name.eq_ignore_ascii_case("content-type"))
305            .map(|(_, value)| value)
306            .collect();
307        let sdk_text = content_types.first().filter(|mime| {
308            mime.split(';')
309                .next()
310                .is_some_and(|essence| essence.trim().eq_ignore_ascii_case("text/plain"))
311        });
312        let core_selected = plugins
313            .iter()
314            .any(|entry| entry.plugin.metadata().id == "core");
315        let mut reports: Vec<_> = plugins
316            .iter()
317            .map(|entry| {
318                let preparation = entry
319                    .sdk_body_profile
320                    .as_ref()
321                    .filter(|profile| {
322                        entry.plugin.supports_http(&endpoint)
323                            && profile.endpoint(&capture.method, &endpoint)
324                            && !decoded.unsupported
325                            && decoded.json_body.is_none()
326                            && sdk_text.is_some()
327                            && !context.body_unavailable()
328                            && !context.header_unknown("content-type", true)
329                    })
330                    .and_then(|profile| {
331                        decoded
332                            .entity_text
333                            .as_deref()
334                            .filter(|text| text.contains('='))
335                            .map(|text| {
336                                profile.prepare(
337                                    text,
338                                    sdk_text.expect("checked"),
339                                    endpoint
340                                        .params(crate::manifest::ParamStyle::Query)
341                                        .iter()
342                                        .any(|field| field.name == "data"),
343                                )
344                            })
345                    });
346                if let Some(preparation) = preparation {
347                    let mut owner = PreparedArtifact::from_request_at(&url_request, clock);
348                    owner.mark_complete_http_request();
349                    let mut sdk_secrets = Vec::new();
350                    match preparation {
351                        crate::sdk_body::Preparation::Data(data) => {
352                            sdk_secrets = data.secrets;
353                            owner.set_form_fields(vec![crate::sdk_body::field(data.data)]);
354                        }
355                        crate::sdk_body::Preparation::Deferred(problem) => {
356                            owner.mark_unavailable_form_body();
357                            sdk_violations.push(problem.finding());
358                        }
359                    }
360                    entry.plugin.prepare_vendor_context(&mut owner);
361                    let mut report = entry.plugin.validate_http(
362                        &owner,
363                        body.as_ref(),
364                        decoded.entity_bytes,
365                        &decoded.normalized,
366                        core_selected,
367                    );
368                    let secrets = sdk_secrets;
369                    let prefix = format!("{}.body.", entry.plugin.metadata().id.replace('/', "."));
370                    for violation in report
371                        .violations
372                        .iter_mut()
373                        .filter(|finding| finding.code.starts_with(&prefix))
374                    {
375                        violation.message = sdk_redact(&violation.message, &secrets);
376                        if let Some(hint) = &mut violation.fix_hint {
377                            *hint = sdk_redact(hint, &secrets);
378                        }
379                    }
380                    report
381                } else {
382                    entry.plugin.validate_http(
383                        &prepared,
384                        body.as_ref(),
385                        decoded.entity_bytes,
386                        &decoded.normalized,
387                        core_selected,
388                    )
389                }
390            })
391            .collect();
392        let mut input_violations = decoded.violations;
393        input_violations.extend(sdk_violations);
394        if let Some(body) = &body {
395            input_violations.extend(CoreRulePack::default().validate_prepared(body).violations);
396        }
397        if !input_violations.is_empty() {
398            if let Some(core) = reports.iter_mut().find(|report| report.plugin_id == "core") {
399                core.violations.extend(input_violations);
400            } else {
401                reports.insert(0, core_report(input_violations));
402            }
403        }
404        if let Some(report) = self.directory_report(&prepared, options, &reports, &vendor_rulepacks)
405        {
406            reports.push(report);
407        }
408        // Normalized JSON and decoded-body byte ranges are not capture ranges.
409        // Keep field names and source citations, never fabricate input offsets.
410        let mut secrets = secret_values(&capture);
411        if binary_body.is_some()
412            && let Some(json) = decoded.json_body.as_deref()
413            && let Ok(value) = serde_json::from_str::<Value>(json)
414        {
415            collect_entity_secrets(&value, false, &mut secrets);
416        }
417        for field in prepared.params(crate::manifest::ParamStyle::Query) {
418            let name = field.name.to_ascii_lowercase();
419            if name.contains("key")
420                || name.contains("token")
421                || name.contains("secret")
422                || name.contains("password")
423            {
424                secrets.push(field.value.to_string());
425            }
426        }
427        secrets.retain(|value| !value.is_empty());
428        secrets.sort_by_key(|value| std::cmp::Reverse(value.len()));
429        secrets.dedup();
430        for report in &mut reports {
431            for violation in &mut report.violations {
432                violation.targets.clear();
433                for secret in &secrets {
434                    violation.message = violation.message.replace(secret, "[redacted]");
435                    if let Some(hint) = &mut violation.fix_hint {
436                        *hint = hint.replace(secret, "[redacted]");
437                    }
438                }
439            }
440        }
441        Ok(ValidationSummary::from_evaluated(reports, vendor_rulepacks))
442    }
443}
444
445fn core_report(violations: Vec<Violation>) -> ValidationReport {
446    ValidationReport {
447        plugin_id: "core".into(),
448        detected_vendor: None,
449        violations,
450    }
451}
452
453fn request_violation(
454    code: &str,
455    field: Option<&str>,
456    message: &str,
457    severity: Severity,
458) -> Violation {
459    Violation {
460        code: format!("core.request.{code}"),
461        message: message.into(),
462        severity,
463        field: field.map(str::to_owned),
464        fix_hint: None,
465        source: RuleSource::normative(
466            "HTTP Semantics (RFC 9110)",
467            "https://www.rfc-editor.org/rfc/rfc9110",
468        ),
469        targets: Vec::new(),
470    }
471}
472
473fn token(value: &str) -> bool {
474    !value.is_empty()
475        && value
476            .bytes()
477            .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte))
478}
479
480fn insert_repeated(object: &mut Map<String, Value>, name: String, value: String) {
481    match object.get_mut(&name) {
482        None => {
483            object.insert(name, Value::String(value));
484        }
485        Some(Value::Array(values)) => values.push(Value::String(value)),
486        Some(previous) => {
487            *previous = Value::Array(vec![previous.take(), Value::String(value)]);
488        }
489    }
490}
491
492fn header_lines(headers: &HttpHeaders) -> Vec<(&str, &str)> {
493    match headers {
494        HttpHeaders::Object(headers) => headers
495            .iter()
496            .map(|(name, value)| (name.as_str(), value.as_str()))
497            .collect(),
498        HttpHeaders::List(headers) => headers
499            .iter()
500            .map(|header| (header.name.as_str(), header.value.as_str()))
501            .collect(),
502    }
503}
504
505/// Keep capture uncertainty separate from observed absent MIME. The optional
506/// importer metadata must never turn redacted content into observed evidence.
507fn capture_allows_headerless_form(request: &HttpRequest, context: &HttpCaptureContext) -> bool {
508    let headers = header_lines(&request.headers);
509    let present = |name: &str| {
510        headers
511            .iter()
512            .any(|(key, _)| key.eq_ignore_ascii_case(name))
513    };
514    !context.header_unknown("content-type", present("content-type"))
515        && !context.header_unknown("content-encoding", present("content-encoding"))
516        && !context.body_unavailable()
517        && context.body != Some(HttpBodyAvailability::Absent)
518}
519
520fn decode_request_with_form_hint(
521    request: &HttpRequest,
522    context: &HttpCaptureContext,
523    binary_body: Option<&str>,
524    clock: i64,
525    headerless_form: bool,
526) -> DecodedRequest {
527    let mut violations = Vec::new();
528    if !token(&request.method) && detect_macro_spans(&request.method).is_empty() {
529        violations.push(request_violation(
530            "invalid_method",
531            Some("method"),
532            "HTTP method must be a nonempty token.",
533            Severity::Error,
534        ));
535    }
536    let mut headers = Map::new();
537    for (name, value) in header_lines(&request.headers) {
538        if !token(name) && detect_macro_spans(name).is_empty() {
539            violations.push(request_violation(
540                "invalid_header",
541                Some("headers"),
542                "HTTP header names must be nonempty tokens.",
543                Severity::Error,
544            ));
545        }
546        if context.redacted_header(name) {
547            continue;
548        }
549        if value.bytes().any(|byte| {
550            byte == 0
551                || byte == b'\r'
552                || byte == b'\n'
553                || (byte < 32 && byte != b'\t')
554                || byte == 127
555        }) {
556            violations.push(request_violation(
557                "invalid_header",
558                Some("headers"),
559                "HTTP header values cannot contain control characters other than horizontal tab.",
560                Severity::Error,
561            ));
562        }
563        insert_repeated(
564            &mut headers,
565            name.to_ascii_lowercase(),
566            value.trim_matches([' ', '\t']).into(),
567        );
568    }
569    let ambiguous_content_type = headers.get("content-type").is_some_and(Value::is_array);
570    let content_type = headers
571        .get("content-type")
572        .and_then(Value::as_str)
573        .map(|value| {
574            value
575                .split(';')
576                .next()
577                .unwrap_or_default()
578                .trim()
579                .to_ascii_lowercase()
580        });
581    let has_body = request.body.is_some() || binary_body.is_some();
582    let encoding = match (has_body, content_type.as_deref()) {
583        (false, _) => "none",
584        (true, Some("application/x-www-form-urlencoded")) => "form",
585        (true, None) if headerless_form && !headers.contains_key("content-type") => "form",
586        (true, Some("application/x-ndjson")) => "ndjson",
587        (true, Some("multipart/form-data")) => "multipart",
588        (true, Some(mime)) if mime == "application/json" || mime.ends_with("+json") => "json",
589        _ => "text",
590    };
591    let mut entity_text = request.body.clone();
592    let mut wire_bytes = request.body.as_ref().map_or(0, String::len);
593    let capture_body_unavailable = context.body_unavailable();
594    let mut unavailable_paths = Vec::new();
595    if context.headers_unavailable {
596        unavailable_paths.push("headers".to_string());
597    }
598    for name in context
599        .unavailable_headers
600        .iter()
601        .chain(&context.redacted_headers)
602    {
603        let name = name.to_ascii_lowercase();
604        if context.header_unknown(&name, headers.contains_key(&name)) {
605            unavailable_paths.push(crate::json::member_path("headers", &name));
606        }
607    }
608    if context.header_unknown("authorization", headers.contains_key("authorization")) {
609        unavailable_paths.extend(["authorization_scheme".into(), "basic_auth".into()]);
610    }
611    let unavailable_content_type =
612        context.header_unknown("content-type", headers.contains_key("content-type"));
613    let unavailable_content_encoding =
614        context.header_unknown("content-encoding", headers.contains_key("content-encoding"));
615    if unavailable_content_type {
616        unavailable_paths.extend(["content_type".into(), "body_encoding".into()]);
617    }
618    if capture_body_unavailable {
619        unavailable_paths.extend(["body".into(), "body_encoding".into()]);
620    }
621    let decoder_context_unavailable =
622        has_body && (unavailable_content_type || unavailable_content_encoding);
623    if decoder_context_unavailable {
624        unavailable_paths.extend(["body".into(), "body_encoding".into()]);
625    }
626    if !unavailable_paths.is_empty() {
627        let mut finding = request_violation(
628            "capture_incomplete",
629            Some("capture"),
630            "Declared capture fields are unavailable or redacted. Checks that depend on them remain unvalidated; observed method, URL and other fields still run.",
631            Severity::Info,
632        );
633        finding.source = RuleSource::normative(
634            "HTTP Archive 1.2 request representation",
635            "https://webperfwg.org/specs/HAR/Overview.html#request",
636        );
637        violations.push(finding);
638    }
639    let compressed = headers.get("content-encoding").is_some_and(|value| {
640        value
641            .as_str()
642            .is_none_or(|value| !value.eq_ignore_ascii_case("identity"))
643    });
644    let gzip = headers
645        .get("content-encoding")
646        .and_then(Value::as_str)
647        .is_some_and(|value| value.eq_ignore_ascii_case("gzip"));
648    let mut unsupported = capture_body_unavailable
649        || decoder_context_unavailable
650        || (has_body
651            && ((compressed && !(binary_body.is_some() && gzip))
652                || ambiguous_content_type
653                || content_type.as_deref().is_some_and(|mime| {
654                    mime.starts_with("multipart/") && mime != "multipart/form-data"
655                })));
656    if unsupported && !capture_body_unavailable && !decoder_context_unavailable {
657        violations.push(request_violation("unsupported_body_encoding", Some("body"), "This capture has unsupported or ambiguous entity encoding. Provide raw decoded text with matching headers, or explicit body_base64 wire bytes with a supported Content-Encoding, for local body checks.", Severity::Info));
658    }
659    if !unsupported && let Some(encoded) = binary_body {
660        match crate::binary_body::decode(encoded, gzip) {
661            Ok(decoded) => {
662                wire_bytes = decoded.wire_bytes;
663                entity_text = Some(decoded.text);
664            }
665            Err(problem) => {
666                let (code, severity, message, reference) = match problem {
667                    crate::binary_body::BinaryBodyError::Base64 => (
668                        "invalid_base64_body",
669                        Severity::Error,
670                        "body_base64 must contain canonical standard Base64 with required padding and no whitespace.",
671                        "https://www.rfc-editor.org/rfc/rfc4648",
672                    ),
673                    crate::binary_body::BinaryBodyError::Gzip => (
674                        "invalid_gzip_body",
675                        Severity::Error,
676                        "The captured gzip entity has invalid framing, compressed data, checksum, size, truncation or trailing bytes.",
677                        "https://www.rfc-editor.org/rfc/rfc1952",
678                    ),
679                    crate::binary_body::BinaryBodyError::Utf8
680                        if matches!(encoding, "json" | "ndjson") =>
681                    {
682                        (
683                            "invalid_body_utf8",
684                            Severity::Error,
685                            "The captured JSON entity is not UTF-8 encoded.",
686                            "https://www.rfc-editor.org/rfc/rfc8259#section-8.1",
687                        )
688                    }
689                    crate::binary_body::BinaryBodyError::Utf8 => (
690                        "unsupported_body_encoding",
691                        Severity::Info,
692                        "The captured binary entity is outside local UTF-8 text inspection. Payload checks remain unvalidated; no vendor rejection is inferred.",
693                        "https://github.com/aleksUIX/pixellint/blob/main/docs/HTTP_REQUEST_SCHEMA.md",
694                    ),
695                    crate::binary_body::BinaryBodyError::Limit => (
696                        "body_decode_limit",
697                        Severity::Info,
698                        "Binary entity inspection exceeded a local resource bound: 16 MiB wire bytes, 16 MiB decoded bytes, 1024 gzip members or 64 KiB header per member. Payload checks remain unvalidated; no vendor rejection is inferred.",
699                        "https://github.com/aleksUIX/pixellint/blob/main/docs/HTTP_REQUEST_SCHEMA.md",
700                    ),
701                };
702                let mut finding = request_violation(code, Some("body_base64"), message, severity);
703                finding.source = if severity == Severity::Info {
704                    RuleSource {
705                        level: crate::RuleSourceLevel::Heuristic,
706                        name: "Pixellint local binary inspection scope".into(),
707                        reference: Some(reference.into()),
708                    }
709                } else {
710                    RuleSource::normative("Captured binary entity decoding", reference)
711                };
712                violations.push(finding);
713                entity_text = None;
714                unsupported = true;
715            }
716        }
717    }
718    let wire = entity_text.as_deref();
719    let mut decoded_body = Value::Null;
720    let mut json_body = None;
721    let mut multipart_fields = None;
722    if !unsupported && let Some(body) = wire {
723        match encoding {
724            "multipart" => {
725                let raw_type = headers
726                    .get("content-type")
727                    .and_then(Value::as_str)
728                    .unwrap_or_default();
729                match crate::multipart::decode(raw_type, body) {
730                    Ok(fields) => {
731                        let mut values = Map::new();
732                        for field in &fields {
733                            insert_repeated(
734                                &mut values,
735                                field.name.to_string(),
736                                field.value.to_string(),
737                            );
738                        }
739                        decoded_body = Value::Object(values);
740                        multipart_fields = Some(fields);
741                    }
742                    Err(problem) => {
743                        let (code, severity, message) = match problem {
744                            crate::multipart::MultipartError::Invalid(message) => {
745                                ("invalid_multipart", Severity::Error, message)
746                            }
747                            crate::multipart::MultipartError::Unsupported(message) => {
748                                ("unsupported_body_encoding", Severity::Info, message)
749                            }
750                        };
751                        let mut finding = request_violation(code, Some("body"), message, severity);
752                        finding.source = RuleSource::normative(
753                            "Multipart form data (RFC 7578 and RFC 2046)",
754                            "https://www.rfc-editor.org/rfc/rfc7578",
755                        );
756                        violations.push(finding);
757                        unsupported = true;
758                    }
759                }
760            }
761            "form" if body.trim_start().starts_with(['{', '[']) => {
762                // Some official curl examples send raw JSON with curl's form
763                // default. Keep the MIME evidence while inspecting the entity.
764                json_body = Some(body.into());
765                decoded_body = decode_json_entity(body);
766            }
767            "form" => {
768                let mut fields = Map::new();
769                for (name, value) in url::form_urlencoded::parse(body.as_bytes()) {
770                    insert_repeated(&mut fields, name.into_owned(), value.into_owned());
771                }
772                decoded_body = Value::Object(fields);
773            }
774            "ndjson" => {
775                let mut values = Vec::new();
776                let mut valid = true;
777                for line in body.lines().filter(|line| !line.trim().is_empty()) {
778                    match serde_json::from_str::<Value>(line) {
779                        Ok(value) => values.push(value),
780                        Err(_) => {
781                            valid = false;
782                            break;
783                        }
784                    }
785                }
786                if valid {
787                    decoded_body = Value::Array(values);
788                    json_body = Some(decoded_body.to_string());
789                } else {
790                    violations.push(request_violation(
791                        "invalid_ndjson",
792                        Some("body"),
793                        "Each nonempty NDJSON line must be valid JSON.",
794                        Severity::Error,
795                    ));
796                }
797            }
798            _ if encoding == "json" || body.trim_start().starts_with(['{', '[']) => {
799                json_body = Some(body.into());
800                decoded_body = decode_json_entity(body);
801            }
802            _ => decoded_body = Value::String(body.into()),
803        }
804    }
805    if json_body
806        .as_ref()
807        .is_some_and(|body| crate::json::JsonDocument::parse(body).is_err())
808    {
809        decoded_body = Value::Null;
810    }
811    let mut normalized = Map::new();
812    normalized.insert("url".into(), Value::String(request.url.clone()));
813    normalized.insert("method".into(), Value::String(request.method.clone()));
814    let url_request = ValidationRequest {
815        artifact_kind: ArtifactKind::Url,
816        artifact: request.url.clone(),
817        claimed_vendor: None,
818        expansion_state: crate::ExpansionState::Unknown,
819    };
820    let parsed = PreparedArtifact::from_request_at(&url_request, clock);
821    if let Some(url) = parsed.url() {
822        normalized.insert("path".into(), Value::String(url.path.to_string()));
823        if let Some(host) = &url.host {
824            normalized.insert("host".into(), Value::String(host.to_string()));
825        }
826    }
827    let mut query = Map::new();
828    let without_fragment = request.url.split('#').next().unwrap_or_default();
829    if let Some((_, query_text)) = without_fragment.split_once('?') {
830        for (name, value) in
831            url::form_urlencoded::parse(query_text.split('#').next().unwrap_or_default().as_bytes())
832        {
833            insert_repeated(&mut query, name.into_owned(), value.into_owned());
834        }
835    }
836    normalized.insert("query".into(), Value::Object(query));
837    if let Some(content_type) = &content_type {
838        normalized.insert("content_type".into(), Value::String(content_type.clone()));
839    }
840    if let Some(auth) = headers.get("authorization").and_then(Value::as_str)
841        && let Some((scheme, credentials)) = auth.split_once([' ', '\t'])
842    {
843        normalized.insert(
844            "authorization_scheme".into(),
845            Value::String(scheme.to_ascii_lowercase()),
846        );
847        if scheme.eq_ignore_ascii_case("basic")
848            && let Ok(decoded) =
849                crate::manifest::decode_base64_json(credentials.trim_start_matches([' ', '\t']))
850        {
851            let (username, password, has_colon) = match decoded.split_once(':') {
852                Some((username, password)) => (username, password, true),
853                None => (decoded.as_str(), "", false),
854            };
855            normalized.insert("basic_auth".into(), serde_json::json!({ "username":username, "password":password, "has_colon":has_colon }));
856        }
857    }
858    if ambiguous_content_type || unavailable_content_type {
859        normalized.insert("content_type_unavailable".into(), Value::Bool(true));
860    }
861    if binary_body.is_some() && unsupported {
862        unavailable_paths.extend(["wire_body_bytes".into(), "entity_body_bytes".into()]);
863    }
864    if !unavailable_paths.is_empty() {
865        normalized.insert(
866            "capture_unavailable_paths".into(),
867            serde_json::json!(unavailable_paths),
868        );
869    }
870    normalized.insert("headers".into(), Value::Object(headers));
871    if binary_body.is_some() && !unsupported {
872        normalized.insert("wire_body_bytes".into(), serde_json::json!(wire_bytes));
873        normalized.insert(
874            "entity_body_bytes".into(),
875            serde_json::json!(wire.map_or(0, str::len)),
876        );
877    }
878    normalized.insert("body".into(), decoded_body);
879    normalized.insert(
880        "body_encoding".into(),
881        Value::String(if unsupported { "unsupported" } else { encoding }.into()),
882    );
883    let unavailable_form = unsupported
884        && (ambiguous_content_type
885            || encoding == "form"
886            || decoder_context_unavailable
887            || (capture_body_unavailable && content_type.is_none())
888            || content_type_from_headers(request)
889                .is_some_and(|mime| mime.starts_with("multipart/")));
890    let form = encoding == "form" && !unsupported && json_body.is_none();
891    DecodedRequest {
892        normalized: Value::Object(normalized).to_string(),
893        json_body,
894        form,
895        multipart_fields,
896        unavailable_form,
897        entity_bytes: wire.map_or(0, str::len),
898        unsupported,
899        entity_text,
900        violations,
901    }
902}
903
904fn content_type_from_headers(request: &HttpRequest) -> Option<String> {
905    header_lines(&request.headers)
906        .into_iter()
907        .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
908        .map(|(_, value)| {
909            value
910                .split(';')
911                .next()
912                .unwrap_or_default()
913                .trim()
914                .to_ascii_lowercase()
915        })
916}
917
918fn decode_json_entity(body: &str) -> Value {
919    // Preserve the engine's bounded entity parser. A rejected deep entity must
920    // not also prevent observable method and header contracts from running.
921    if crate::json::JsonDocument::parse(body).is_err() {
922        return Value::Null;
923    }
924    serde_json::from_str(body).unwrap_or(Value::Null)
925}
926
927fn secret_values(request: &HttpRequest) -> Vec<String> {
928    let mut values = Vec::new();
929    for (name, value) in header_lines(&request.headers) {
930        let name = name.to_ascii_lowercase();
931        if name.contains("authorization")
932            || name.contains("cookie")
933            || name.contains("key")
934            || name.contains("token")
935            || name == "authentication"
936        {
937            values.push(value.to_owned());
938            let value = value.trim_matches([' ', '\t']);
939            values.push(value.to_owned());
940            if let Some((scheme, credential)) = value.split_once([' ', '\t']) {
941                let credential = credential.trim_start_matches([' ', '\t']);
942                values.push(credential.to_owned());
943                if scheme.eq_ignore_ascii_case("basic")
944                    && let Ok(decoded) = crate::manifest::decode_base64_json(credential)
945                {
946                    values.push(decoded.clone());
947                    if let Some((username, password)) = decoded.split_once(':') {
948                        values.extend([username.to_owned(), password.to_owned()]);
949                    }
950                }
951            }
952        }
953    }
954    values.retain(|value| !value.is_empty());
955    values.sort_by_key(|value| std::cmp::Reverse(value.len()));
956    values.dedup();
957    values
958}
959
960fn collect_entity_secrets(value: &Value, secret: bool, values: &mut Vec<String>) {
961    match value {
962        Value::Object(object) => {
963            for (name, value) in object {
964                let name = name.to_ascii_lowercase();
965                let sensitive = secret
966                    || [
967                        "authorization",
968                        "cookie",
969                        "key",
970                        "token",
971                        "secret",
972                        "password",
973                    ]
974                    .iter()
975                    .any(|part| name.contains(part));
976                collect_entity_secrets(value, sensitive, values);
977            }
978        }
979        Value::Array(array) => {
980            for value in array {
981                collect_entity_secrets(value, secret, values);
982            }
983        }
984        Value::String(value) if secret => values.push(value.clone()),
985        _ if secret && !value.is_null() => values.push(value.to_string()),
986        _ => {}
987    }
988}
989
990/// Match only original text. Inserted placeholders are never redacted again.
991fn sdk_redact(text: &str, secrets: &[String]) -> String {
992    if secrets.is_empty() {
993        return text.into();
994    }
995    let mut output = String::new();
996    let mut index = 0;
997    while index < text.len() {
998        let tail = &text[index..];
999        if let Some(secret) = secrets
1000            .iter()
1001            .find(|secret| tail.starts_with(secret.as_str()))
1002        {
1003            output.push_str("[redacted]");
1004            index += secret.len();
1005        } else {
1006            let character = tail.chars().next().expect("remaining text");
1007            output.push(character);
1008            index += character.len_utf8();
1009        }
1010    }
1011    output
1012}
1013
1014#[cfg(test)]
1015mod sdk_sanitizer_tests {
1016    use super::sdk_redact;
1017    #[test]
1018    fn replacement_text_is_not_rescanned_for_short_or_overlapping_secrets() {
1019        let secrets = vec!["redacted".into(), "a".into()];
1020        assert_eq!(sdk_redact("a redacted", &secrets), "[redacted] [redacted]");
1021        assert_eq!(sdk_redact("雪a😀", &secrets), "雪[redacted]😀");
1022    }
1023}