Skip to main content

termivar_scanner/
http_evidence.rs

1//! Scope-bound HTTP collection for the decision runner.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** default via `scanning`.
6//! - **Execution:** Surface B (metered executor for the decision runtime).
7//! - **Default `termivar scan`:** yes, through `StandardWebDecisionRuntime`.
8//! - **Support:** implemented and tested.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! This executor performs one bounded discovery request and emits immutable,
13//! typed observations. It does not classify vulnerabilities, follow redirects,
14//! choose follow-up actions, or mutate the knowledge base directly.
15
16use std::{collections::BTreeSet, sync::Arc, time::Duration};
17
18use async_trait::async_trait;
19#[cfg(any(test, all(fuzzing, feature = "wordpress-review")))]
20use reqwest::header::HeaderValue;
21use reqwest::{header::HeaderMap, header::HeaderName, StatusCode, Url};
22use sha2::{Digest, Sha256};
23use termivar_core::{
24    ConfidenceScore, DerivationAlgorithm, Evidence, EvidenceDerivation, EvidenceId, EvidenceKind,
25    EvidenceSource, EvidenceValue, HttpEvidencePredicate, KnowledgePredicate,
26};
27use thiserror::Error;
28
29use crate::web_runtime::{
30    project_assessment_defense_signal, AssessmentDefenseBodyCoverage,
31    AssessmentDefenseProjectionContext, AssessmentDefenseSignal,
32};
33use crate::{
34    defense::{DefenseState, MAX_FINGERPRINT_BODY_SCAN_BYTES},
35    payload_strategy::{
36        PayloadSeed, PayloadStrategyLimits, PayloadStrategyRef, PayloadStrategyRegistry,
37        PayloadVariantRole,
38    },
39    DecisionActionExecutor, DecisionExecutionFailureKind, DecisionExecutionRequest,
40    DecisionExecutionStage, DecisionExecutorError,
41};
42
43#[cfg(test)]
44use crate::runtime_budget::RequestAccountingBroker;
45
46mod form_controls;
47#[cfg(feature = "supplied-session-review")]
48mod login_form;
49pub(crate) mod passive_review;
50mod policy;
51mod probe;
52mod request_broker;
53mod response;
54mod review_response;
55
56use form_controls::{extract_form_control_names, FormControlExtraction};
57#[cfg(feature = "supplied-session-review")]
58pub(crate) use login_form::{
59    extract_supplied_session_login_csrf, SuppliedSessionLoginCsrfToken,
60    SuppliedSessionLoginFormError,
61};
62use passive_review::{project_passive_response, PassiveResponseProjection};
63use policy::forbidden_request_header;
64pub use policy::{
65    HttpBodyCapture, HttpEvidencePolicy, DEFAULT_HTTP_BODY_LIMIT, MAX_HTTP_BODY_LIMIT,
66};
67pub use probe::{HttpProbe, HttpProbeMethod, HttpProbeProvider, SubjectHttpProbeProvider};
68#[cfg(feature = "xml-external-entity-owned-https-test-profile")]
69pub(crate) use request_broker::OwnedXmlHttpsTestTransportProfile;
70#[cfg(feature = "supplied-session-review")]
71pub(crate) use request_broker::SuppliedSessionRequestError;
72#[cfg(feature = "wordpress-review")]
73pub(crate) use request_broker::{
74    wordpress_asset_request_binding_value, WordPressAssetRequestDescriptor,
75    WordPressMetadataRequestDescriptor, WordPressMetadataRequestSource,
76    WordPressMetadataResourceKind, WORDPRESS_ASSET_FINGERPRINT_POLICY_ID,
77    WORDPRESS_ASSET_NOMINATION_COMPONENT, WORDPRESS_ASSET_NOMINATION_METHOD,
78    WORDPRESS_ASSET_NOMINATION_NAMESPACE, WORDPRESS_ASSET_NOMINATION_PREDICATE,
79    WORDPRESS_METADATA_DISCOVERY_POLICY_ID,
80};
81pub(crate) use request_broker::{HttpRequestBroker, HttpRequestBrokerError};
82#[cfg(feature = "jwt-target-acceptance-review")]
83pub(crate) use request_broker::{
84    JwtTargetAcceptanceRequestDescriptor, JwtTargetAcceptanceRequestError,
85};
86#[cfg(feature = "supplied-session-review")]
87pub(crate) use request_broker::{
88    OAuthCodeRequestBroker, OAuthCodeRequestDescriptor, OAuthCodeRequestError,
89    OAuthRefreshRequestDescriptor,
90};
91#[cfg(feature = "xml-external-entity-review")]
92pub(crate) use request_broker::{
93    XmlExternalEntityRequestBroker, XmlExternalEntityRequestDescriptor,
94};
95#[cfg(feature = "authorization-review")]
96pub(crate) use response::AuthorizationResponseDefense;
97pub(crate) use response::CollectedHttpResponse;
98#[cfg(feature = "supplied-session-review")]
99pub(crate) use response::{
100    SuppliedSessionCookieUpdateClassification, SuppliedSessionCookieUpdateObservation,
101};
102#[cfg(test)]
103pub(crate) use review_response::{
104    project_review_response, ReviewResponseProjection, EMPTY_REVIEW_RESPONSE_PROJECTION,
105};
106pub(crate) use review_response::{
107    CorsAllowCredentialsRelation, CorsAllowOriginRelation, LocationRelation, VaryOriginRelation,
108};
109
110/// Stable executor identity used by the standard HTTP evidence collector.
111pub const HTTP_EVIDENCE_EXECUTOR_ID: &str = "http.evidence";
112
113const MAX_HTTP_PATH_SEGMENTS: usize = 128;
114const MAX_HTTP_PATH_SEGMENT_BYTES: usize = 256;
115const MAX_HTTP_QUERY_PAYLOAD_PARAMETER_BYTES: usize = 64;
116const MAX_ASSESSMENT_DEFENSE_HEADER_OCCURRENCES: usize = 8;
117const MAX_ASSESSMENT_DEFENSE_HEADER_VALUE_BYTES: usize = 1024;
118const MAX_ASSESSMENT_DEFENSE_COOKIE_NAME_BYTES: usize = 256;
119#[cfg(feature = "wordpress-review")]
120const MAX_WORDPRESS_LINK_HEADER_OCCURRENCES: usize = 4;
121#[cfg(feature = "wordpress-review")]
122const MAX_WORDPRESS_LINK_HEADER_BYTES: usize = 4 * 1024;
123
124/// Bounded projection of an advertised WordPress REST index. Raw `Link`
125/// values never cross the HTTP evidence boundary.
126#[cfg(feature = "wordpress-review")]
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub(crate) enum WordPressRestIndexAdvertisement {
129    Missing,
130    Candidate(Url),
131    InvalidOrAmbiguous,
132}
133
134/// Configuration and execution failures for HTTP evidence collection.
135#[derive(Debug, Error)]
136#[non_exhaustive]
137pub enum HttpEvidenceError {
138    /// At least one explicit authorized origin is required.
139    #[error("HTTP evidence policy must contain at least one allowed origin")]
140    EmptyAllowedOrigins,
141
142    /// The request timeout must be positive.
143    #[error("HTTP evidence request timeout must be greater than zero")]
144    ZeroTimeout,
145
146    /// The response-body limit must be positive.
147    #[error("HTTP evidence body limit must be greater than zero")]
148    ZeroBodyLimit,
149
150    /// Evidence consumed by deterministic rules needs non-zero reliability.
151    #[error("HTTP evidence reliability must be greater than zero")]
152    ZeroReliability,
153
154    /// The response-body limit exceeded the hard per-request bound.
155    #[error("HTTP evidence body limit {actual} exceeds maximum {maximum}")]
156    BodyLimitTooLarge { actual: usize, maximum: usize },
157
158    /// Text sampling requires at least one character.
159    #[error("HTTP evidence text sample limit must be greater than zero")]
160    ZeroTextSampleLimit,
161
162    /// A text sample cannot exceed the byte buffer guarding the response.
163    #[error("HTTP text sample limit {max_chars} exceeds body byte limit {max_body_bytes}")]
164    TextSampleLimitTooLarge {
165        /// Requested character limit.
166        max_chars: usize,
167        /// Configured response byte limit.
168        max_body_bytes: usize,
169    },
170
171    /// Only absolute HTTP and HTTPS destinations are supported.
172    #[error("unsupported HTTP evidence URL scheme {scheme}")]
173    UnsupportedScheme { scheme: String },
174
175    /// Embedded URL credentials could leak through request or evidence logs.
176    #[error("HTTP evidence URL must not contain embedded credentials")]
177    EmbeddedCredentials,
178
179    /// A decision subject did not use the endpoint URL identity convention.
180    #[error("decision subject {subject} is not an endpoint URL identity")]
181    InvalidEndpointSubject { subject: String },
182
183    /// The executor registry requires a stable non-empty identity.
184    #[error("HTTP evidence executor id must not be empty")]
185    EmptyExecutorId,
186
187    /// An absolute URL could not be parsed.
188    #[error("invalid HTTP evidence URL {value}: {source}")]
189    InvalidUrl {
190        /// Rejected URL string.
191        value: String,
192        /// URL parser diagnostic.
193        #[source]
194        source: url::ParseError,
195    },
196
197    /// A request or captured response header name was invalid.
198    #[error("invalid HTTP header name {name}")]
199    InvalidHeaderName { name: String },
200
201    /// A request header value was invalid.
202    #[error("invalid value for HTTP request header {name}")]
203    InvalidHeaderValue { name: String },
204
205    /// A streaming or otherwise opaque body could bypass byte accounting.
206    #[error("HTTP request body length is unavailable to the accounting broker")]
207    UnmeteredRequestBody,
208
209    /// The opt-in GraphQL review attempted to exceed its fixed JSON request bound.
210    #[cfg(feature = "graphql-review")]
211    #[error("GraphQL review request body exceeds its compiled byte limit")]
212    GraphqlReviewRequestBodyLimit,
213
214    /// A WordPress metadata request did not match the selected application's
215    /// closed role/resource admission descriptor.
216    #[cfg(feature = "wordpress-review")]
217    #[error("WordPress metadata request violated its closed admission contract")]
218    InvalidWordPressMetadataRequest,
219
220    /// A WordPress asset request was not the exact JS/CSS resource admitted
221    /// from committed HTML under the frozen application/component binding.
222    #[cfg(feature = "wordpress-review")]
223    #[error("WordPress asset request violated its closed admission contract")]
224    InvalidWordPressAssetRequest,
225
226    /// A controlled XML request did not match its immutable policy, exact
227    /// endpoint, fixed role, or canonical body binding.
228    #[cfg(feature = "xml-external-entity-review")]
229    #[error("controlled XML request violated its closed admission contract")]
230    InvalidXmlExternalEntityRequest,
231
232    /// A request header could alter destination or message framing.
233    #[error("HTTP request header {name} is forbidden by evidence policy")]
234    ForbiddenRequestHeader { name: String },
235
236    /// A provider attempted to leave the authorized origin set.
237    #[error("HTTP evidence target origin is outside policy: {url}")]
238    TargetOutsidePolicy { url: String },
239
240    /// A bound payload strategy is not present in its registry.
241    #[error("payload strategy {strategy} is not registered for this executor")]
242    PayloadStrategyUnavailable {
243        /// Stable strategy identity and revision.
244        strategy: String,
245    },
246
247    /// A bound payload strategy could not derive its artifact for this turn.
248    #[error("payload strategy {strategy} failed to derive a {role} artifact")]
249    PayloadDerivationFailed {
250        /// Stable strategy identity and revision.
251        strategy: String,
252        /// Control or candidate role requested for the turn.
253        role: &'static str,
254    },
255
256    /// A native query-payload binding used an invalid fixed parameter name.
257    #[error("HTTP query payload parameter is invalid")]
258    InvalidQueryPayloadParameter,
259
260    /// Query-payload pairs require a query-free base request so the control is
261    /// unambiguous and no existing value can be overwritten or retained.
262    #[error("HTTP query payload binding requires a query-free base probe")]
263    QueryPayloadRequiresQueryFreeProbe,
264
265    /// The redirect-disabled HTTP client could not be constructed.
266    #[error("failed to construct HTTP evidence client: {0}")]
267    Client(#[source] reqwest::Error),
268
269    /// The total request and bounded body read timed out.
270    #[error("HTTP evidence request timed out after {timeout_ms} ms")]
271    Timeout { timeout_ms: u64 },
272
273    /// Request construction or transport failed.
274    #[error("HTTP evidence request failed: {0}")]
275    Request(#[source] reqwest::Error),
276
277    /// Core reasoning values could not be constructed.
278    #[error("failed to construct HTTP evidence: {0}")]
279    Reasoning(#[from] termivar_core::ReasoningModelError),
280
281    /// The assessment-only defense projection could not bind its exact base
282    /// evidence set. No response values are included in this diagnostic.
283    #[error("assessment defense projection violated its fixed evidence boundary")]
284    AssessmentDefenseProjectionInvariant,
285
286    /// The sealed assessment observer did not receive its required non-secret
287    /// base evidence identities.
288    #[error("HTTP assessment observer invariant failed: {invariant}")]
289    AssessmentObserverInvariant { invariant: &'static str },
290}
291
292pub(crate) fn execution_failure_kind(error: &HttpEvidenceError) -> DecisionExecutionFailureKind {
293    match error {
294        HttpEvidenceError::InvalidEndpointSubject { .. }
295        | HttpEvidenceError::UnsupportedScheme { .. } => {
296            DecisionExecutionFailureKind::NotApplicable
297        },
298        #[cfg(feature = "graphql-review")]
299        HttpEvidenceError::GraphqlReviewRequestBodyLimit => {
300            DecisionExecutionFailureKind::BlockedByPolicy
301        },
302        #[cfg(feature = "wordpress-review")]
303        HttpEvidenceError::InvalidWordPressMetadataRequest
304        | HttpEvidenceError::InvalidWordPressAssetRequest => {
305            DecisionExecutionFailureKind::BlockedByPolicy
306        },
307        #[cfg(feature = "xml-external-entity-review")]
308        HttpEvidenceError::InvalidXmlExternalEntityRequest => {
309            DecisionExecutionFailureKind::BlockedByPolicy
310        },
311        HttpEvidenceError::EmbeddedCredentials
312        | HttpEvidenceError::ForbiddenRequestHeader { .. }
313        | HttpEvidenceError::UnmeteredRequestBody
314        | HttpEvidenceError::TargetOutsidePolicy { .. } => {
315            DecisionExecutionFailureKind::BlockedByPolicy
316        },
317        HttpEvidenceError::Timeout { .. } => DecisionExecutionFailureKind::RequestTimeout,
318        HttpEvidenceError::Request(_) => DecisionExecutionFailureKind::TransportFailure,
319        HttpEvidenceError::EmptyAllowedOrigins
320        | HttpEvidenceError::ZeroTimeout
321        | HttpEvidenceError::ZeroBodyLimit
322        | HttpEvidenceError::ZeroReliability
323        | HttpEvidenceError::BodyLimitTooLarge { .. }
324        | HttpEvidenceError::ZeroTextSampleLimit
325        | HttpEvidenceError::TextSampleLimitTooLarge { .. }
326        | HttpEvidenceError::EmptyExecutorId
327        | HttpEvidenceError::InvalidUrl { .. }
328        | HttpEvidenceError::InvalidHeaderName { .. }
329        | HttpEvidenceError::InvalidHeaderValue { .. }
330        | HttpEvidenceError::PayloadStrategyUnavailable { .. }
331        | HttpEvidenceError::PayloadDerivationFailed { .. }
332        | HttpEvidenceError::InvalidQueryPayloadParameter
333        | HttpEvidenceError::QueryPayloadRequiresQueryFreeProbe
334        | HttpEvidenceError::Client(_)
335        | HttpEvidenceError::Reasoning(_)
336        | HttpEvidenceError::AssessmentDefenseProjectionInvariant
337        | HttpEvidenceError::AssessmentObserverInvariant { .. } => {
338            DecisionExecutionFailureKind::ExecutorFailure
339        },
340    }
341}
342
343pub(crate) fn into_decision_executor_error(error: HttpEvidenceError) -> DecisionExecutorError {
344    DecisionExecutorError::with_kind(execution_failure_kind(&error), error.to_string())
345}
346
347/// Role label used in payload-derivation diagnostics.
348fn payload_role_name(role: PayloadVariantRole) -> &'static str {
349    match role {
350        PayloadVariantRole::Control => "control",
351        PayloadVariantRole::Candidate => "candidate",
352    }
353}
354
355/// Binds a header-valued payload strategy to the HTTP evidence executor.
356///
357/// When a decision case selects this binding's strategy reference, the executor
358/// derives exactly one artifact per turn from the registry and applies it as the
359/// value of `header` before dispatch. Passive turns derive the `Control`
360/// artifact; explicit active verification turns derive the `Candidate` artifact,
361/// aligning differential work with the existing evidence transaction boundary.
362///
363/// The derived bytes never bypass [`HttpProbe`] header validation, so the same
364/// forbidden-header and value rules that guard hand-built probes also guard
365/// strategy-materialized ones.
366#[derive(Clone)]
367pub struct HttpHeaderPayloadBinding {
368    registry: PayloadStrategyRegistry,
369    reference: PayloadStrategyRef,
370    seed: PayloadSeed,
371    limits: PayloadStrategyLimits,
372    header: String,
373}
374
375impl HttpHeaderPayloadBinding {
376    /// Binds `reference` to a request header, validating both up front.
377    ///
378    /// Fails when the header name is empty, malformed, or forbidden by evidence
379    /// policy, or when `reference` is not registered in `registry`. Binding the
380    /// registry at construction keeps derivation a pure, in-process step.
381    pub fn new(
382        registry: PayloadStrategyRegistry,
383        reference: PayloadStrategyRef,
384        seed: PayloadSeed,
385        limits: PayloadStrategyLimits,
386        header: impl Into<String>,
387    ) -> Result<Self, HttpEvidenceError> {
388        let header = header.into();
389        let parsed = HeaderName::from_bytes(header.as_bytes()).map_err(|_| {
390            HttpEvidenceError::InvalidHeaderName {
391                name: header.clone(),
392            }
393        })?;
394        if forbidden_request_header(&parsed) {
395            return Err(HttpEvidenceError::ForbiddenRequestHeader {
396                name: parsed.as_str().to_owned(),
397            });
398        }
399        if !registry.contains(&reference) {
400            return Err(HttpEvidenceError::PayloadStrategyUnavailable {
401                strategy: reference.to_string(),
402            });
403        }
404        Ok(Self {
405            registry,
406            reference,
407            seed,
408            limits,
409            header: parsed.as_str().to_owned(),
410        })
411    }
412
413    /// Returns the strategy reference this binding materializes.
414    pub fn reference(&self) -> &PayloadStrategyRef {
415        &self.reference
416    }
417
418    /// Returns the request header the derived artifact is applied to.
419    pub fn header(&self) -> &str {
420        &self.header
421    }
422
423    /// Derives the stage-appropriate artifact and applies it to `probe`.
424    ///
425    /// Passive turns map to a `Control` artifact and active turns to a
426    /// `Candidate` artifact. Derivation, provenance revalidation, and byte
427    /// bounds are all enforced by the registry; header validation is enforced by
428    /// [`HttpProbe::with_header`]. An empty derived artifact omits the header
429    /// entirely, letting a control leg represent an anonymous context.
430    fn apply_to_probe(
431        &self,
432        stage: DecisionExecutionStage,
433        probe: HttpProbe,
434    ) -> Result<HttpProbe, HttpEvidenceError> {
435        let role = match stage {
436            DecisionExecutionStage::Passive => PayloadVariantRole::Control,
437            DecisionExecutionStage::Active => PayloadVariantRole::Candidate,
438        };
439        let artifact = self
440            .registry
441            .derive_one(&self.reference, role, &self.seed, self.limits)
442            .map_err(|_| HttpEvidenceError::PayloadDerivationFailed {
443                strategy: self.reference.to_string(),
444                role: payload_role_name(role),
445            })?;
446        let value = std::str::from_utf8(artifact.as_bytes()).map_err(|_| {
447            HttpEvidenceError::PayloadDerivationFailed {
448                strategy: self.reference.to_string(),
449                role: payload_role_name(role),
450            }
451        })?;
452        // An empty derived artifact intentionally omits the header, so a control
453        // leg can represent an anonymous context rather than an empty value.
454        if value.is_empty() {
455            return Ok(probe);
456        }
457        probe.with_header(self.header.clone(), value)
458    }
459}
460
461/// Binds a payload strategy to one fixed query-parameter name.
462///
463/// The base probe must be query-free. Passive turns derive an empty control
464/// artifact and therefore preserve that exact query-free request; active turns
465/// append exactly one encoded name/value pair. The destination origin, path,
466/// method, and request headers are never changed by this binding.
467#[derive(Clone)]
468pub(crate) struct HttpQueryPayloadBinding {
469    registry: PayloadStrategyRegistry,
470    reference: PayloadStrategyRef,
471    seed: PayloadSeed,
472    limits: PayloadStrategyLimits,
473    parameter: String,
474}
475
476impl HttpQueryPayloadBinding {
477    pub(crate) fn new(
478        registry: PayloadStrategyRegistry,
479        reference: PayloadStrategyRef,
480        seed: PayloadSeed,
481        limits: PayloadStrategyLimits,
482        parameter: impl Into<String>,
483    ) -> Result<Self, HttpEvidenceError> {
484        let parameter = parameter.into();
485        if parameter.is_empty()
486            || parameter.len() > MAX_HTTP_QUERY_PAYLOAD_PARAMETER_BYTES
487            || !parameter.bytes().all(|byte| {
488                byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~')
489            })
490        {
491            return Err(HttpEvidenceError::InvalidQueryPayloadParameter);
492        }
493        if !registry.contains(&reference) {
494            return Err(HttpEvidenceError::PayloadStrategyUnavailable {
495                strategy: reference.to_string(),
496            });
497        }
498        Ok(Self {
499            registry,
500            reference,
501            seed,
502            limits,
503            parameter,
504        })
505    }
506
507    pub(crate) fn reference(&self) -> &PayloadStrategyRef {
508        &self.reference
509    }
510
511    #[cfg(test)]
512    pub(crate) fn parameter(&self) -> &str {
513        &self.parameter
514    }
515
516    fn apply_to_probe(
517        &self,
518        stage: DecisionExecutionStage,
519        mut probe: HttpProbe,
520    ) -> Result<HttpProbe, HttpEvidenceError> {
521        if probe.url().query().is_some() {
522            return Err(HttpEvidenceError::QueryPayloadRequiresQueryFreeProbe);
523        }
524        let role = match stage {
525            DecisionExecutionStage::Passive => PayloadVariantRole::Control,
526            DecisionExecutionStage::Active => PayloadVariantRole::Candidate,
527        };
528        let artifact = self
529            .registry
530            .derive_one(&self.reference, role, &self.seed, self.limits)
531            .map_err(|_| HttpEvidenceError::PayloadDerivationFailed {
532                strategy: self.reference.to_string(),
533                role: payload_role_name(role),
534            })?;
535        let value = std::str::from_utf8(artifact.as_bytes()).map_err(|_| {
536            HttpEvidenceError::PayloadDerivationFailed {
537                strategy: self.reference.to_string(),
538                role: payload_role_name(role),
539            }
540        })?;
541        if value.is_empty() {
542            return Ok(probe);
543        }
544        probe
545            .url_mut()
546            .query_pairs_mut()
547            .append_pair(&self.parameter, value);
548        Ok(probe)
549    }
550}
551
552#[derive(Clone)]
553enum HttpPayloadBinding {
554    Header(HttpHeaderPayloadBinding),
555    Query(HttpQueryPayloadBinding),
556}
557
558impl HttpPayloadBinding {
559    fn reference(&self) -> &PayloadStrategyRef {
560        match self {
561            Self::Header(binding) => binding.reference(),
562            Self::Query(binding) => binding.reference(),
563        }
564    }
565
566    fn apply_to_probe(
567        &self,
568        stage: DecisionExecutionStage,
569        probe: HttpProbe,
570    ) -> Result<HttpProbe, HttpEvidenceError> {
571        match self {
572            Self::Header(binding) => binding.apply_to_probe(stage, probe),
573            Self::Query(binding) => binding.apply_to_probe(stage, probe),
574        }
575    }
576}
577
578/// Real HTTP executor that produces typed evidence for the decision runner.
579///
580/// # Examples
581///
582/// ```rust,no_run
583/// use std::sync::Arc;
584/// use url::Url;
585/// use termivar_scanner::{
586///     DecisionActionExecutor, HttpEvidenceExecutor, HttpEvidencePolicy, HttpProbeProvider,
587///     SubjectHttpProbeProvider,
588/// };
589///
590/// let target = Url::parse("https://example.test/")?;
591/// let policy = HttpEvidencePolicy::for_origin(target)?;
592/// let probes: Arc<dyn HttpProbeProvider> =
593///     Arc::new(SubjectHttpProbeProvider::default());
594/// let executor = HttpEvidenceExecutor::new(policy, probes)?;
595///
596/// assert_eq!(executor.id(), "http.evidence");
597/// # Ok::<(), Box<dyn std::error::Error>>(())
598/// ```
599pub struct HttpEvidenceExecutor {
600    id: String,
601    requests: HttpRequestBroker,
602    probes: Arc<dyn HttpProbeProvider>,
603    payload: Option<HttpPayloadBinding>,
604    capture_form_control_names: bool,
605    complete_response_observer: Option<Arc<dyn CompleteHttpResponseObserver>>,
606    assessment_defense_projection: bool,
607}
608
609impl HttpEvidenceExecutor {
610    /// Creates a redirect-disabled executor with the standard identity.
611    pub fn new(
612        policy: HttpEvidencePolicy,
613        probes: Arc<dyn HttpProbeProvider>,
614    ) -> Result<Self, HttpEvidenceError> {
615        Self::with_id(HTTP_EVIDENCE_EXECUTOR_ID, policy, probes)
616    }
617
618    /// Creates a redirect-disabled executor with a host-selected identity.
619    pub fn with_id(
620        id: impl Into<String>,
621        policy: HttpEvidencePolicy,
622        probes: Arc<dyn HttpProbeProvider>,
623    ) -> Result<Self, HttpEvidenceError> {
624        let id = validate_executor_id(id)?;
625        let requests = HttpRequestBroker::new_unmetered(policy)?;
626        Ok(Self {
627            id,
628            requests,
629            probes,
630            payload: None,
631            capture_form_control_names: false,
632            complete_response_observer: None,
633            assessment_defense_projection: false,
634        })
635    }
636
637    #[cfg(test)]
638    pub(crate) fn new_with_accounting(
639        policy: HttpEvidencePolicy,
640        probes: Arc<dyn HttpProbeProvider>,
641        accounting: RequestAccountingBroker,
642    ) -> Result<Self, HttpEvidenceError> {
643        Self::with_id_and_accounting(HTTP_EVIDENCE_EXECUTOR_ID, policy, probes, accounting)
644    }
645
646    #[cfg(test)]
647    pub(crate) fn with_id_and_accounting(
648        id: impl Into<String>,
649        policy: HttpEvidencePolicy,
650        probes: Arc<dyn HttpProbeProvider>,
651        accounting: RequestAccountingBroker,
652    ) -> Result<Self, HttpEvidenceError> {
653        let id = validate_executor_id(id)?;
654        let requests = HttpRequestBroker::new_metered(policy, accounting)?;
655        Ok(Self {
656            id,
657            requests,
658            probes,
659            payload: None,
660            capture_form_control_names: false,
661            complete_response_observer: None,
662            assessment_defense_projection: false,
663        })
664    }
665
666    pub(crate) fn new_with_request_broker(
667        requests: HttpRequestBroker,
668        probes: Arc<dyn HttpProbeProvider>,
669    ) -> Result<Self, HttpEvidenceError> {
670        Self::with_id_and_request_broker(HTTP_EVIDENCE_EXECUTOR_ID, requests, probes)
671    }
672
673    pub(crate) fn with_id_and_request_broker(
674        id: impl Into<String>,
675        requests: HttpRequestBroker,
676        probes: Arc<dyn HttpProbeProvider>,
677    ) -> Result<Self, HttpEvidenceError> {
678        let id = validate_executor_id(id)?;
679        Ok(Self {
680            id,
681            requests,
682            probes,
683            payload: None,
684            capture_form_control_names: false,
685            complete_response_observer: None,
686            assessment_defense_projection: false,
687        })
688    }
689
690    /// Enables conservative HTML form-control-name discovery for this executor.
691    ///
692    /// When enabled, and only when the body-capture policy authorizes a bounded
693    /// [`HttpBodyCapture::TextSample`] and the response is `text/html`, the
694    /// executor reads named form controls from the *same* bounded sample and
695    /// emits [`HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES`]. It never
696    /// opens a second content-capture path, and never records control values.
697    /// This is a deliberately narrow opt-in, not a generic extractor hook.
698    pub(crate) fn with_form_control_capture(mut self) -> Self {
699        self.capture_form_control_names = true;
700        self
701    }
702
703    /// Installs the crate-owned response projection seam used by the origin
704    /// assessment runtime.
705    ///
706    /// The trait and observation type are crate-private. An observer can see a
707    /// body only after the broker has observed stream EOF; partial bytes are
708    /// never exposed through this seam. Its evidence is returned in the same
709    /// executor batch as the HTTP observations, so the decision runner either
710    /// commits all of it or none of it.
711    pub(crate) fn with_complete_response_observer(
712        mut self,
713        observer: Arc<dyn CompleteHttpResponseObserver>,
714    ) -> Self {
715        self.complete_response_observer = Some(observer);
716        self
717    }
718
719    /// Enables the closed, namespaced defense projection used only by the
720    /// origin assessment runtime. No raw response material leaves this
721    /// executor; only bounded typed signals enter the atomic evidence batch.
722    pub(crate) fn with_assessment_defense_projection(mut self) -> Self {
723        self.assessment_defense_projection = true;
724        self
725    }
726
727    /// Binds a header-valued payload strategy this executor will materialize.
728    ///
729    /// The executor then advertises exact support for the binding's strategy
730    /// reference and, when a decision case selects it, derives one control or
731    /// candidate artifact per turn and applies it before dispatch. Actions that
732    /// do not select the reference are unaffected, so an executor may serve both
733    /// plain discovery and strategy-driven differential turns.
734    pub fn with_payload_binding(mut self, binding: HttpHeaderPayloadBinding) -> Self {
735        self.payload = Some(HttpPayloadBinding::Header(binding));
736        self
737    }
738
739    /// Installs the crate-owned query-pair binding used by native bounded web
740    /// review actions. It is intentionally not a public arbitrary transport
741    /// setting.
742    pub(crate) fn with_query_payload_binding(mut self, binding: HttpQueryPayloadBinding) -> Self {
743        self.payload = Some(HttpPayloadBinding::Query(binding));
744        self
745    }
746
747    /// Returns the bound payload strategy reference, if any.
748    pub fn payload_strategy_reference(&self) -> Option<&PayloadStrategyRef> {
749        self.payload.as_ref().map(HttpPayloadBinding::reference)
750    }
751
752    /// Returns the immutable execution policy.
753    pub fn policy(&self) -> &HttpEvidencePolicy {
754        self.requests.policy()
755    }
756
757    /// Resolves the base probe and applies the bound payload artifact when the
758    /// decision case selects this executor's strategy reference.
759    fn resolve_probe(
760        &self,
761        decision: &DecisionExecutionRequest,
762    ) -> Result<HttpProbe, HttpEvidenceError> {
763        let probe = self.probes.probe_for(decision)?;
764        match (self.payload.as_ref(), decision.payload_strategy()) {
765            (Some(binding), Some(strategy)) if binding.reference() == strategy => {
766                binding.apply_to_probe(decision.stage(), probe)
767            },
768            _ => Ok(probe),
769        }
770    }
771
772    async fn collect(
773        &self,
774        decision: &DecisionExecutionRequest,
775    ) -> Result<Vec<Evidence>, HttpRequestBrokerError> {
776        let probe = self.resolve_probe(decision)?;
777        let collected = self.requests.collect(decision, &probe).await?;
778        self.to_evidence(decision, &probe, collected)
779            .map_err(Into::into)
780    }
781
782    fn to_evidence(
783        &self,
784        decision: &DecisionExecutionRequest,
785        probe: &HttpProbe,
786        response: CollectedHttpResponse,
787    ) -> Result<Vec<Evidence>, HttpEvidenceError> {
788        let mut evidence = vec![
789            self.observation(
790                decision,
791                EvidenceKind::Http,
792                HttpEvidencePredicate::REQUEST_METHOD.into(),
793                EvidenceValue::Text(probe.method().as_str().to_owned()),
794                "request-method",
795            )?,
796            self.observation(
797                decision,
798                EvidenceKind::Http,
799                HttpEvidencePredicate::REQUEST_URL.into(),
800                EvidenceValue::Text(probe.url().to_string()),
801                "request-url",
802            )?,
803            self.observation(
804                decision,
805                EvidenceKind::Http,
806                HttpEvidencePredicate::RESPONSE_STATUS.into(),
807                EvidenceValue::Unsigned(u64::from(response.status.as_u16())),
808                "response-status",
809            )?,
810            self.observation(
811                decision,
812                EvidenceKind::Http,
813                HttpEvidencePredicate::RESPONSE_FINAL_URL.into(),
814                EvidenceValue::Text(response.final_url.to_string()),
815                "response-final-url",
816            )?,
817            self.observation(
818                decision,
819                EvidenceKind::Http,
820                HttpEvidencePredicate::RESPONSE_VERSION.into(),
821                EvidenceValue::Text(response.version.clone()),
822                "response-version",
823            )?,
824            self.observation(
825                decision,
826                EvidenceKind::Timing,
827                HttpEvidencePredicate::TIMING_TTFB_MS.into(),
828                EvidenceValue::Unsigned(response.ttfb_ms),
829                "time-to-first-byte",
830            )?,
831            self.observation(
832                decision,
833                EvidenceKind::Timing,
834                HttpEvidencePredicate::TIMING_TOTAL_MS.into(),
835                EvidenceValue::Unsigned(response.total_ms),
836                "total-response-time",
837            )?,
838        ];
839
840        let path_segments: BTreeSet<_> = probe
841            .url()
842            .path_segments()
843            .into_iter()
844            .flatten()
845            .filter(|segment| !segment.is_empty() && segment.len() <= MAX_HTTP_PATH_SEGMENT_BYTES)
846            .take(MAX_HTTP_PATH_SEGMENTS)
847            .map(str::to_owned)
848            .collect();
849        for segment in path_segments {
850            evidence.push(self.observation(
851                decision,
852                EvidenceKind::Http,
853                HttpEvidencePredicate::REQUEST_PATH_SEGMENT.into(),
854                EvidenceValue::Text(segment),
855                "request-path-segment",
856            )?);
857        }
858
859        for name in self.policy().captured_headers() {
860            if let Some(value) = joined_header(&response.headers, name) {
861                evidence.push(self.observation(
862                    decision,
863                    EvidenceKind::Http,
864                    HttpEvidencePredicate::response_header(name.clone())?,
865                    EvidenceValue::Text(value),
866                    &format!("response-header:{name}"),
867                )?);
868            }
869        }
870
871        if let Some(media_type) = normalized_media_type(&response.headers) {
872            let json_compatible = json_compatible_media_type(&media_type);
873            evidence.push(self.observation(
874                decision,
875                EvidenceKind::Http,
876                HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into(),
877                EvidenceValue::Text(media_type),
878                "response-media-type",
879            )?);
880            evidence.push(self.observation(
881                decision,
882                EvidenceKind::Http,
883                HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into(),
884                EvidenceValue::Boolean(json_compatible),
885                "response-media-type-json-compatibility",
886            )?);
887        }
888
889        // The sealed assessment observer owns a stricter bounded Set-Cookie
890        // projection. Do not duplicate its names through the historical generic
891        // extractor, whose contract predates the assessment-wide occurrence
892        // ceiling. Baseline/default executors retain the existing behavior.
893        if self.complete_response_observer.is_none() {
894            for cookie_name in response_cookie_names(&response.headers) {
895                evidence.push(self.observation(
896                    decision,
897                    EvidenceKind::Authentication,
898                    HttpEvidencePredicate::COOKIE_NAME.into(),
899                    EvidenceValue::Text(cookie_name),
900                    "response-set-cookie-name",
901                )?);
902            }
903        }
904
905        evidence.push(self.observation(
906            decision,
907            EvidenceKind::Content,
908            HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into(),
909            EvidenceValue::Unsigned(u64::try_from(response.body.len()).unwrap_or(u64::MAX)),
910            "response-body-size",
911        )?);
912        evidence.push(self.observation(
913            decision,
914            EvidenceKind::Content,
915            HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into(),
916            EvidenceValue::Boolean(response.body_truncated),
917            "response-body-truncation",
918        )?);
919        // The ordinary assessment observer may classify this response as
920        // secret-bearing. Reserve an opaque identity now, but construct and hash
921        // the public body-digest record only after the sealed observer decides
922        // that retaining it is safe. The insertion index preserves the exact
923        // option-off evidence order.
924        let response_body_digest_evidence_id = EvidenceId::new();
925        let response_body_digest_insert_index = evidence.len();
926        if self.complete_response_observer.is_none() {
927            evidence.push(self.observation_with_id(
928                response_body_digest_evidence_id.clone(),
929                decision,
930                EvidenceKind::Content,
931                HttpEvidencePredicate::RESPONSE_BODY_SHA256.into(),
932                EvidenceValue::Text(format!("{:x}", Sha256::digest(&response.body))),
933                "response-body-sha256",
934            )?);
935        }
936
937        if let HttpBodyCapture::TextSample { max_chars } = self.policy().body_capture() {
938            if textual_response(&response.headers) {
939                let decoded = String::from_utf8_lossy(&response.body);
940                let sample: String = decoded.chars().take(max_chars).collect();
941
942                // Form-control discovery reads the SAME bounded sample and only
943                // for text/html. It cannot run under MetadataOnly (no sample is
944                // computed here at all), so the body-capture policy is never
945                // bypassed. Only control names are recorded, never values, and
946                // only when at least one name is conservatively observed. Names
947                // are extracted from the borrowed sample first, before the
948                // sample is moved into its own observation.
949                let form_control_names = if self.capture_form_control_names
950                    && normalized_media_type(&response.headers).as_deref() == Some("text/html")
951                {
952                    match extract_form_control_names(&sample) {
953                        FormControlExtraction::Observed(names) if !names.is_empty() => Some(names),
954                        _ => None,
955                    }
956                } else {
957                    None
958                };
959
960                // Build the body-sample observation so a derived form-control
961                // record can cite its exact EvidenceId as lineage. The body
962                // sample is the sole transformation input; the media type and
963                // truncation observations are gating/context, not lineage.
964                let body_sample = self.observation(
965                    decision,
966                    EvidenceKind::Content,
967                    HttpEvidencePredicate::RESPONSE_BODY_SAMPLE.into(),
968                    EvidenceValue::Text(sample),
969                    "response-body-sample",
970                )?;
971
972                if let Some(names) = form_control_names {
973                    let derivation = EvidenceDerivation::new(
974                        [body_sample.id().clone()],
975                        form_control_derivation_algorithm(),
976                    )?;
977                    evidence.push(
978                        self.observation(
979                            decision,
980                            EvidenceKind::Content,
981                            HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES.into(),
982                            EvidenceValue::TextList(names),
983                            "response-form-control-names",
984                        )?
985                        .derived_from(derivation),
986                    );
987                }
988
989                // Preserve the historical batch order: form-control names (when
990                // present) precede the body sample.
991                evidence.push(body_sample);
992            }
993        }
994
995        append_rate_limit_evidence(self, decision, &response, &mut evidence)?;
996        let mut response_body_lineage_evidence_id = None;
997        if let Some(observer) = &self.complete_response_observer {
998            let media_type = normalized_media_type(&response.headers);
999            // Raw headers remain local to the executor. Only this bounded,
1000            // value-free projection crosses the sealed observer boundary.
1001            let passive_response_projection = project_passive_response(&response.headers);
1002            let review_response_projection =
1003                review_response::project_review_response(probe, &response.headers);
1004            #[cfg(feature = "wordpress-review")]
1005            let wordpress_rest_index_advertisement =
1006                project_wordpress_rest_index_advertisement(probe.url(), &response.headers);
1007            let evidence_id = |predicate: termivar_core::PredicateDescriptor| {
1008                let predicate = predicate.into_knowledge();
1009                evidence
1010                    .iter()
1011                    .find(|item| item.predicate() == &predicate)
1012                    .map(|item| item.id())
1013            };
1014            let observation = CompleteHttpResponseObservation {
1015                case_id: decision.case().id(),
1016                action_id: decision.case().action_id(),
1017                executor_id: &self.id,
1018                hypothesis_id: decision.case().hypothesis_id(),
1019                has_payload_strategy: decision.case().payload_strategy().is_some(),
1020                payload_strategy: decision.case().payload_strategy(),
1021                applies_hypothesis_transition: decision.case().applies_hypothesis_transition(),
1022                stage: decision.stage(),
1023                subject: decision.case().subject(),
1024                method: probe.method(),
1025                requested_url: probe.url(),
1026                status: response.status.as_u16(),
1027                media_type: media_type.as_deref(),
1028                reliability: self.policy().reliability(),
1029                complete_body: (response.body_complete && !response.body_truncated)
1030                    .then_some(response.body.as_slice()),
1031                #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1032                request_headers_empty: probe.headers().is_empty(),
1033                #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1034                response_final_url_matches_request: response.final_url == *probe.url(),
1035                #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1036                response_content_encoding_absent: !response
1037                    .headers
1038                    .contains_key(reqwest::header::CONTENT_ENCODING),
1039                #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1040                response_content_length_consistent: response::content_length_matches_body(
1041                    &response.headers,
1042                    u64::try_from(response.body.len()).unwrap_or(u64::MAX),
1043                ),
1044                #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1045                response_body_bytes: u64::try_from(response.body.len()).unwrap_or(u64::MAX),
1046                request_method_evidence_id: evidence_id(HttpEvidencePredicate::REQUEST_METHOD),
1047                request_url_evidence_id: evidence_id(HttpEvidencePredicate::REQUEST_URL),
1048                response_status_evidence_id: evidence_id(HttpEvidencePredicate::RESPONSE_STATUS),
1049                response_final_url_evidence_id: evidence_id(
1050                    HttpEvidencePredicate::RESPONSE_FINAL_URL,
1051                ),
1052                response_media_type_evidence_id: evidence_id(
1053                    HttpEvidencePredicate::RESPONSE_MEDIA_TYPE,
1054                ),
1055                response_body_truncated_evidence_id: evidence_id(
1056                    HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1057                ),
1058                #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1059                response_body_bytes_evidence_id: evidence_id(
1060                    HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1061                ),
1062                // This reserved ID lets legacy/option-off observers construct
1063                // their existing derivations before the digest record is inserted.
1064                // A suppressing observer must not cite it and instead returns one
1065                // committed, non-secret lineage record from its own projection.
1066                response_body_digest_evidence_id: Some(&response_body_digest_evidence_id),
1067                passive_response_projection: &passive_response_projection,
1068                review_response_projection: &review_response_projection,
1069                #[cfg(feature = "wordpress-review")]
1070                wordpress_rest_index_advertisement: &wordpress_rest_index_advertisement,
1071            };
1072            let (projected, digest_disposition) = observer.observe(observation)?;
1073            match digest_disposition {
1074                ResponseBodyDigestDisposition::Retain => {
1075                    evidence.insert(
1076                        response_body_digest_insert_index,
1077                        self.observation_with_id(
1078                            response_body_digest_evidence_id.clone(),
1079                            decision,
1080                            EvidenceKind::Content,
1081                            HttpEvidencePredicate::RESPONSE_BODY_SHA256.into(),
1082                            EvidenceValue::Text(format!("{:x}", Sha256::digest(&response.body))),
1083                            "response-body-sha256",
1084                        )?,
1085                    );
1086                },
1087                ResponseBodyDigestDisposition::Suppress {
1088                    lineage_evidence_id,
1089                } => {
1090                    let lineage_count = projected
1091                        .iter()
1092                        .filter(|item| item.id() == &lineage_evidence_id)
1093                        .count();
1094                    let publishes_body_digest = projected.iter().any(|item| {
1095                        item.predicate()
1096                            == &HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge()
1097                    });
1098                    let cites_discarded_digest = projected.iter().any(|item| {
1099                        item.origin().derivation().is_some_and(|derivation| {
1100                            derivation
1101                                .parents()
1102                                .contains(&response_body_digest_evidence_id)
1103                        })
1104                    });
1105                    if lineage_count != 1 || publishes_body_digest || cites_discarded_digest {
1106                        return Err(HttpEvidenceError::AssessmentObserverInvariant {
1107                            invariant: "suppressed body digest lineage",
1108                        });
1109                    }
1110                    response_body_lineage_evidence_id = Some(lineage_evidence_id);
1111                },
1112            }
1113            evidence.extend(projected);
1114        }
1115        if self.assessment_defense_projection {
1116            let signal = bounded_assessment_defense_signal(
1117                response.status.as_u16(),
1118                probe.method(),
1119                &response.headers,
1120                response.body_complete,
1121                &response.body,
1122            );
1123            let parent_predicates = [
1124                HttpEvidencePredicate::REQUEST_METHOD,
1125                HttpEvidencePredicate::REQUEST_URL,
1126                HttpEvidencePredicate::RESPONSE_STATUS,
1127                HttpEvidencePredicate::RESPONSE_FINAL_URL,
1128                HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1129                HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1130                HttpEvidencePredicate::RATE_LIMIT_DETECTED,
1131                HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
1132            ];
1133            let mut parents = Vec::with_capacity(parent_predicates.len() + 1);
1134            for descriptor in parent_predicates {
1135                let predicate = descriptor.into_knowledge();
1136                let mut matching = evidence
1137                    .iter()
1138                    .filter(|item| item.predicate() == &predicate);
1139                let Some(parent) = matching.next() else {
1140                    return Err(HttpEvidenceError::AssessmentDefenseProjectionInvariant);
1141                };
1142                if matching.next().is_some() {
1143                    return Err(HttpEvidenceError::AssessmentDefenseProjectionInvariant);
1144                }
1145                parents.push(parent.id().clone());
1146            }
1147            if let Some(lineage) = response_body_lineage_evidence_id {
1148                parents.push(lineage);
1149            } else {
1150                let predicate = HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge();
1151                let mut matching = evidence
1152                    .iter()
1153                    .filter(|item| item.predicate() == &predicate);
1154                let Some(parent) = matching.next() else {
1155                    return Err(HttpEvidenceError::AssessmentDefenseProjectionInvariant);
1156                };
1157                if matching.next().is_some() {
1158                    return Err(HttpEvidenceError::AssessmentDefenseProjectionInvariant);
1159                }
1160                parents.push(parent.id().clone());
1161            }
1162            evidence.extend(project_assessment_defense_signal(
1163                &signal,
1164                AssessmentDefenseProjectionContext {
1165                    subject: decision.case().subject(),
1166                    case_id: decision.case().id(),
1167                    executor_id: &self.id,
1168                    reliability: self.policy().reliability(),
1169                    parents,
1170                },
1171            )?);
1172        }
1173        Ok(evidence)
1174    }
1175
1176    fn observation(
1177        &self,
1178        decision: &DecisionExecutionRequest,
1179        kind: EvidenceKind,
1180        predicate: KnowledgePredicate,
1181        value: EvidenceValue,
1182        method: &str,
1183    ) -> Result<Evidence, HttpEvidenceError> {
1184        let source = EvidenceSource::new(self.id.clone(), method)?
1185            .with_correlation_id(decision.case().id())?;
1186        Ok(Evidence::new(
1187            decision.case().subject().clone(),
1188            kind,
1189            predicate,
1190            value,
1191            source,
1192            self.policy().reliability(),
1193        ))
1194    }
1195
1196    fn observation_with_id(
1197        &self,
1198        id: EvidenceId,
1199        decision: &DecisionExecutionRequest,
1200        kind: EvidenceKind,
1201        predicate: KnowledgePredicate,
1202        value: EvidenceValue,
1203        method: &str,
1204    ) -> Result<Evidence, HttpEvidenceError> {
1205        let source = EvidenceSource::new(self.id.clone(), method)?
1206            .with_correlation_id(decision.case().id())?;
1207        Ok(Evidence::with_id(
1208            id,
1209            decision.case().subject().clone(),
1210            kind,
1211            predicate,
1212            value,
1213            source,
1214            self.policy().reliability(),
1215        ))
1216    }
1217}
1218
1219#[async_trait]
1220impl DecisionActionExecutor for HttpEvidenceExecutor {
1221    fn id(&self) -> &str {
1222        &self.id
1223    }
1224
1225    fn supports_payload_strategy(&self, strategy: &PayloadStrategyRef) -> bool {
1226        self.payload
1227            .as_ref()
1228            .is_some_and(|binding| binding.reference() == strategy)
1229    }
1230
1231    async fn execute(
1232        &self,
1233        request: &DecisionExecutionRequest,
1234    ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1235        self.collect(request)
1236            .await
1237            .map_err(HttpRequestBrokerError::into_decision_executor_error)
1238    }
1239}
1240
1241/// Sealed response projection boundary for crate-owned bounded runtimes.
1242///
1243/// This trait is intentionally not public. In particular, it is not a generic
1244/// host callback that could retain response bodies or mutate transport policy.
1245mod complete_response_observer_seal {
1246    pub(super) trait Sealed {}
1247
1248    impl Sealed for crate::web_runtime::AssessmentDiscoveryObserver {}
1249
1250    impl Sealed for crate::web_runtime::AssessmentReviewObserverSet {}
1251
1252    #[cfg(test)]
1253    impl Sealed for super::tests::RejectingCompleteResponseObserver {}
1254}
1255
1256#[allow(private_bounds)]
1257pub(crate) trait CompleteHttpResponseObserver:
1258    complete_response_observer_seal::Sealed + Send + Sync
1259{
1260    /// Derives safe evidence from one response. `complete_body()` returns bytes
1261    /// only when the broker observed stream EOF.
1262    fn observe(
1263        &self,
1264        observation: CompleteHttpResponseObservation<'_>,
1265    ) -> Result<(Vec<Evidence>, ResponseBodyDigestDisposition), HttpEvidenceError>;
1266}
1267
1268/// Whether the ordinary public body digest is safe to retain. Suppression
1269/// names one value-free evidence record from the same atomic observer
1270/// projection as replacement lineage.
1271pub(crate) enum ResponseBodyDigestDisposition {
1272    Retain,
1273    Suppress { lineage_evidence_id: EvidenceId },
1274}
1275
1276/// Borrowed response view that never exposes a partial body.
1277pub(crate) struct CompleteHttpResponseObservation<'a> {
1278    case_id: &'a str,
1279    action_id: &'a str,
1280    executor_id: &'a str,
1281    hypothesis_id: &'a str,
1282    has_payload_strategy: bool,
1283    payload_strategy: Option<&'a PayloadStrategyRef>,
1284    applies_hypothesis_transition: bool,
1285    stage: DecisionExecutionStage,
1286    subject: &'a termivar_core::EntityId,
1287    method: HttpProbeMethod,
1288    requested_url: &'a Url,
1289    status: u16,
1290    media_type: Option<&'a str>,
1291    reliability: ConfidenceScore,
1292    complete_body: Option<&'a [u8]>,
1293    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1294    request_headers_empty: bool,
1295    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1296    response_final_url_matches_request: bool,
1297    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1298    response_content_encoding_absent: bool,
1299    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1300    response_content_length_consistent: bool,
1301    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1302    response_body_bytes: u64,
1303    request_method_evidence_id: Option<&'a termivar_core::EvidenceId>,
1304    request_url_evidence_id: Option<&'a termivar_core::EvidenceId>,
1305    response_status_evidence_id: Option<&'a termivar_core::EvidenceId>,
1306    response_final_url_evidence_id: Option<&'a termivar_core::EvidenceId>,
1307    response_media_type_evidence_id: Option<&'a termivar_core::EvidenceId>,
1308    response_body_truncated_evidence_id: Option<&'a termivar_core::EvidenceId>,
1309    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1310    response_body_bytes_evidence_id: Option<&'a termivar_core::EvidenceId>,
1311    response_body_digest_evidence_id: Option<&'a termivar_core::EvidenceId>,
1312    passive_response_projection: &'a PassiveResponseProjection,
1313    review_response_projection: &'a review_response::ReviewResponseProjection,
1314    #[cfg(feature = "wordpress-review")]
1315    wordpress_rest_index_advertisement: &'a WordPressRestIndexAdvertisement,
1316}
1317
1318impl CompleteHttpResponseObservation<'_> {
1319    pub(crate) const fn case_id(&self) -> &str {
1320        self.case_id
1321    }
1322
1323    pub(crate) const fn action_id(&self) -> &str {
1324        self.action_id
1325    }
1326
1327    pub(crate) const fn executor_id(&self) -> &str {
1328        self.executor_id
1329    }
1330
1331    pub(crate) const fn hypothesis_id(&self) -> &str {
1332        self.hypothesis_id
1333    }
1334
1335    pub(crate) const fn has_payload_strategy(&self) -> bool {
1336        self.has_payload_strategy
1337    }
1338
1339    pub(crate) const fn payload_strategy(&self) -> Option<&PayloadStrategyRef> {
1340        self.payload_strategy
1341    }
1342
1343    pub(crate) const fn applies_hypothesis_transition(&self) -> bool {
1344        self.applies_hypothesis_transition
1345    }
1346
1347    pub(crate) const fn stage(&self) -> DecisionExecutionStage {
1348        self.stage
1349    }
1350
1351    pub(crate) const fn subject(&self) -> &termivar_core::EntityId {
1352        self.subject
1353    }
1354
1355    pub(crate) const fn method(&self) -> HttpProbeMethod {
1356        self.method
1357    }
1358
1359    pub(crate) const fn requested_url(&self) -> &Url {
1360        self.requested_url
1361    }
1362
1363    pub(crate) const fn status(&self) -> u16 {
1364        self.status
1365    }
1366
1367    pub(crate) fn media_type(&self) -> Option<&str> {
1368        self.media_type
1369    }
1370
1371    pub(crate) const fn reliability(&self) -> ConfidenceScore {
1372        self.reliability
1373    }
1374
1375    pub(crate) const fn complete_body(&self) -> Option<&[u8]> {
1376        self.complete_body
1377    }
1378
1379    /// Returns bytes suitable for the identity-content fingerprint profile.
1380    /// This is stricter than `complete_body()`: the request must have carried
1381    /// no caller headers, the redirect-disabled transport must have retained
1382    /// the exact requested URL, and the response must contain no
1383    /// Content-Encoding field.
1384    #[cfg(feature = "wordpress-review")]
1385    pub(crate) const fn complete_identity_content_body(&self) -> Option<&[u8]> {
1386        if self.request_headers_empty
1387            && self.response_final_url_matches_request
1388            && self.response_content_encoding_absent
1389            && self.response_content_length_consistent
1390        {
1391            self.complete_body
1392        } else {
1393            None
1394        }
1395    }
1396
1397    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1398    pub(crate) const fn response_body_bytes(&self) -> u64 {
1399        self.response_body_bytes
1400    }
1401
1402    #[cfg(feature = "secret-exposure-review")]
1403    pub(crate) const fn request_headers_empty(&self) -> bool {
1404        self.request_headers_empty
1405    }
1406
1407    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1408    pub(crate) const fn response_final_url_matches_request(&self) -> bool {
1409        self.response_final_url_matches_request
1410    }
1411
1412    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1413    pub(crate) const fn response_content_encoding_absent(&self) -> bool {
1414        self.response_content_encoding_absent
1415    }
1416
1417    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1418    pub(crate) const fn response_content_length_consistent(&self) -> bool {
1419        self.response_content_length_consistent
1420    }
1421
1422    pub(crate) const fn request_method_evidence_id(&self) -> Option<&termivar_core::EvidenceId> {
1423        self.request_method_evidence_id
1424    }
1425
1426    pub(crate) const fn request_url_evidence_id(&self) -> Option<&termivar_core::EvidenceId> {
1427        self.request_url_evidence_id
1428    }
1429
1430    pub(crate) const fn response_status_evidence_id(&self) -> Option<&termivar_core::EvidenceId> {
1431        self.response_status_evidence_id
1432    }
1433
1434    pub(crate) const fn response_final_url_evidence_id(
1435        &self,
1436    ) -> Option<&termivar_core::EvidenceId> {
1437        self.response_final_url_evidence_id
1438    }
1439
1440    pub(crate) const fn response_media_type_evidence_id(
1441        &self,
1442    ) -> Option<&termivar_core::EvidenceId> {
1443        self.response_media_type_evidence_id
1444    }
1445
1446    pub(crate) const fn response_body_truncated_evidence_id(
1447        &self,
1448    ) -> Option<&termivar_core::EvidenceId> {
1449        self.response_body_truncated_evidence_id
1450    }
1451
1452    #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1453    pub(crate) const fn response_body_bytes_evidence_id(
1454        &self,
1455    ) -> Option<&termivar_core::EvidenceId> {
1456        self.response_body_bytes_evidence_id
1457    }
1458
1459    pub(crate) const fn response_body_digest_evidence_id(
1460        &self,
1461    ) -> Option<&termivar_core::EvidenceId> {
1462        self.response_body_digest_evidence_id
1463    }
1464
1465    pub(crate) const fn passive_response_projection(&self) -> &PassiveResponseProjection {
1466        self.passive_response_projection
1467    }
1468
1469    pub(crate) const fn review_response_projection(
1470        &self,
1471    ) -> &review_response::ReviewResponseProjection {
1472        self.review_response_projection
1473    }
1474
1475    #[cfg(feature = "wordpress-review")]
1476    pub(crate) const fn wordpress_rest_index_advertisement(
1477        &self,
1478    ) -> &WordPressRestIndexAdvertisement {
1479        self.wordpress_rest_index_advertisement
1480    }
1481}
1482
1483#[cfg(test)]
1484pub(crate) struct CompleteHttpResponseObservationTestInput<'a> {
1485    pub(crate) case_id: &'a str,
1486    pub(crate) action_id: &'a str,
1487    pub(crate) executor_id: &'a str,
1488    pub(crate) hypothesis_id: &'a str,
1489    pub(crate) has_payload_strategy: bool,
1490    pub(crate) payload_strategy: Option<&'a PayloadStrategyRef>,
1491    pub(crate) applies_hypothesis_transition: bool,
1492    pub(crate) stage: DecisionExecutionStage,
1493    pub(crate) subject: &'a termivar_core::EntityId,
1494    pub(crate) method: HttpProbeMethod,
1495    pub(crate) requested_url: &'a Url,
1496    pub(crate) status: u16,
1497    pub(crate) media_type: Option<&'a str>,
1498    pub(crate) reliability: ConfidenceScore,
1499    pub(crate) complete_body: Option<&'a [u8]>,
1500    pub(crate) request_method_evidence_id: Option<&'a termivar_core::EvidenceId>,
1501    pub(crate) request_url_evidence_id: Option<&'a termivar_core::EvidenceId>,
1502    pub(crate) response_status_evidence_id: Option<&'a termivar_core::EvidenceId>,
1503    pub(crate) response_final_url_evidence_id: Option<&'a termivar_core::EvidenceId>,
1504    pub(crate) response_media_type_evidence_id: Option<&'a termivar_core::EvidenceId>,
1505    pub(crate) response_body_truncated_evidence_id: Option<&'a termivar_core::EvidenceId>,
1506    pub(crate) response_body_digest_evidence_id: Option<&'a termivar_core::EvidenceId>,
1507    pub(crate) passive_response_projection: &'a PassiveResponseProjection,
1508    pub(crate) review_response_projection: Option<&'a ReviewResponseProjection>,
1509}
1510
1511#[cfg(test)]
1512pub(crate) fn complete_http_response_observation_for_test<'a>(
1513    input: CompleteHttpResponseObservationTestInput<'a>,
1514) -> CompleteHttpResponseObservation<'a> {
1515    #[cfg(feature = "wordpress-review")]
1516    static MISSING_WORDPRESS_REST_INDEX: WordPressRestIndexAdvertisement =
1517        WordPressRestIndexAdvertisement::Missing;
1518    CompleteHttpResponseObservation {
1519        case_id: input.case_id,
1520        action_id: input.action_id,
1521        executor_id: input.executor_id,
1522        hypothesis_id: input.hypothesis_id,
1523        has_payload_strategy: input.has_payload_strategy,
1524        payload_strategy: input.payload_strategy,
1525        applies_hypothesis_transition: input.applies_hypothesis_transition,
1526        stage: input.stage,
1527        subject: input.subject,
1528        method: input.method,
1529        requested_url: input.requested_url,
1530        status: input.status,
1531        media_type: input.media_type,
1532        reliability: input.reliability,
1533        complete_body: input.complete_body,
1534        #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1535        request_headers_empty: true,
1536        #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1537        response_final_url_matches_request: true,
1538        #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1539        response_content_encoding_absent: true,
1540        #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1541        response_content_length_consistent: true,
1542        #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1543        response_body_bytes: input
1544            .complete_body
1545            .map(|body| u64::try_from(body.len()).unwrap_or(u64::MAX))
1546            .unwrap_or(0),
1547        request_method_evidence_id: input.request_method_evidence_id,
1548        request_url_evidence_id: input.request_url_evidence_id,
1549        response_status_evidence_id: input.response_status_evidence_id,
1550        response_final_url_evidence_id: input.response_final_url_evidence_id,
1551        response_media_type_evidence_id: input.response_media_type_evidence_id,
1552        response_body_truncated_evidence_id: input.response_body_truncated_evidence_id,
1553        #[cfg(any(feature = "wordpress-review", feature = "secret-exposure-review"))]
1554        response_body_bytes_evidence_id: None,
1555        response_body_digest_evidence_id: input.response_body_digest_evidence_id,
1556        passive_response_projection: input.passive_response_projection,
1557        review_response_projection: input
1558            .review_response_projection
1559            .unwrap_or(&EMPTY_REVIEW_RESPONSE_PROJECTION),
1560        #[cfg(feature = "wordpress-review")]
1561        wordpress_rest_index_advertisement: &MISSING_WORDPRESS_REST_INDEX,
1562    }
1563}
1564
1565#[cfg(test)]
1566pub(crate) fn passive_response_projection_for_test(
1567    headers: &[(&'static str, &str)],
1568) -> PassiveResponseProjection {
1569    let mut map = HeaderMap::new();
1570    for (name, value) in headers {
1571        map.append(
1572            *name,
1573            HeaderValue::from_str(value).expect("valid fixture header value"),
1574        );
1575    }
1576    project_passive_response(&map)
1577}
1578
1579fn validate_executor_id(id: impl Into<String>) -> Result<String, HttpEvidenceError> {
1580    let id = id.into();
1581    if id.trim().is_empty() {
1582        return Err(HttpEvidenceError::EmptyExecutorId);
1583    }
1584    Ok(id)
1585}
1586
1587fn joined_header(headers: &HeaderMap, name: &str) -> Option<String> {
1588    let values: Vec<_> = headers
1589        .get_all(name)
1590        .iter()
1591        .filter_map(|value| value.to_str().ok())
1592        .map(str::to_owned)
1593        .collect();
1594    (!values.is_empty()).then(|| values.join(", "))
1595}
1596
1597#[cfg(feature = "wordpress-review")]
1598fn project_wordpress_rest_index_advertisement(
1599    document_url: &Url,
1600    headers: &HeaderMap,
1601) -> WordPressRestIndexAdvertisement {
1602    let values = headers.get_all("link").iter().collect::<Vec<_>>();
1603    if values.is_empty() {
1604        return WordPressRestIndexAdvertisement::Missing;
1605    }
1606    if values.len() > MAX_WORDPRESS_LINK_HEADER_OCCURRENCES {
1607        return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1608    }
1609    let mut total = 0_usize;
1610    let mut selected: Option<Url> = None;
1611    for value in values {
1612        let Ok(value) = value.to_str() else {
1613            return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1614        };
1615        total = match total.checked_add(value.len()) {
1616            Some(total) if total <= MAX_WORDPRESS_LINK_HEADER_BYTES => total,
1617            _ => return WordPressRestIndexAdvertisement::InvalidOrAmbiguous,
1618        };
1619        let Some(parts) = split_http_link_value(value) else {
1620            return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1621        };
1622        for part in parts {
1623            let part = part.trim();
1624            let Some(rest) = part.strip_prefix('<') else {
1625                continue;
1626            };
1627            let Some(target_end) = rest.find('>') else {
1628                return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1629            };
1630            let Some(target) = rest.get(..target_end).filter(|target| !target.is_empty()) else {
1631                return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1632            };
1633            let Some(parameters) = rest.get(target_end.saturating_add(1)..) else {
1634                return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1635            };
1636            let mut is_wordpress_relation = false;
1637            let mut saw_relation_parameter = false;
1638            let Some(parameters) = split_http_link_parameters(parameters) else {
1639                return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1640            };
1641            for parameter in parameters
1642                .into_iter()
1643                .map(str::trim)
1644                .filter(|p| !p.is_empty())
1645            {
1646                let Some((name, raw_value)) = parameter.split_once('=') else {
1647                    continue;
1648                };
1649                if !name.trim().eq_ignore_ascii_case("rel") {
1650                    continue;
1651                }
1652                if saw_relation_parameter {
1653                    return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1654                }
1655                saw_relation_parameter = true;
1656                let raw_value = raw_value.trim();
1657                let relation = raw_value
1658                    .strip_prefix('"')
1659                    .and_then(|value| value.strip_suffix('"'))
1660                    .unwrap_or(raw_value);
1661                is_wordpress_relation = relation
1662                    .split_ascii_whitespace()
1663                    .any(|item| item == "https://api.w.org/");
1664            }
1665            if !is_wordpress_relation {
1666                continue;
1667            }
1668            let Some(candidate) = admitted_wordpress_rest_index(document_url, target) else {
1669                return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1670            };
1671            if selected
1672                .as_ref()
1673                .is_some_and(|existing| existing != &candidate)
1674            {
1675                return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1676            }
1677            selected = Some(candidate);
1678        }
1679    }
1680    selected.map_or(
1681        WordPressRestIndexAdvertisement::Missing,
1682        WordPressRestIndexAdvertisement::Candidate,
1683    )
1684}
1685
1686#[cfg(all(fuzzing, feature = "wordpress-review"))]
1687pub(crate) fn fuzz_wordpress_rest_index_advertisement(
1688    document_url: &Url,
1689    value: &[u8],
1690) -> WordPressRestIndexAdvertisement {
1691    let mut headers = HeaderMap::new();
1692    let Ok(value) = HeaderValue::from_bytes(value) else {
1693        return WordPressRestIndexAdvertisement::InvalidOrAmbiguous;
1694    };
1695    headers.insert("link", value);
1696    let first = project_wordpress_rest_index_advertisement(document_url, &headers);
1697    let repeated = project_wordpress_rest_index_advertisement(document_url, &headers);
1698    assert_eq!(first, repeated, "Link projection must be deterministic");
1699    if let WordPressRestIndexAdvertisement::Candidate(candidate) = &first {
1700        assert_eq!(candidate.origin(), document_url.origin());
1701        assert!(candidate.username().is_empty());
1702        assert!(candidate.password().is_none());
1703        assert!(candidate.fragment().is_none());
1704        if candidate.path().ends_with("/wp-json/") {
1705            assert!(candidate.query().is_none());
1706        } else {
1707            assert!(candidate.path().ends_with('/') || candidate.path().ends_with("/index.php"));
1708            assert!(candidate
1709                .query()
1710                .is_some_and(wordpress_rest_route_query_is_admitted));
1711        }
1712    }
1713    first
1714}
1715
1716#[cfg(feature = "wordpress-review")]
1717fn split_http_link_value(value: &str) -> Option<Vec<&str>> {
1718    let mut parts = Vec::new();
1719    let mut start = 0_usize;
1720    let mut quoted = false;
1721    let mut angled = false;
1722    for (index, byte) in value.bytes().enumerate() {
1723        match byte {
1724            b'"' if !angled => quoted = !quoted,
1725            b'<' if !quoted && !angled => angled = true,
1726            b'>' if !quoted && angled => angled = false,
1727            b',' if !quoted && !angled => {
1728                parts.push(&value[start..index]);
1729                start = index.saturating_add(1);
1730            },
1731            b'\\' | 0..=31 | 127 => return None,
1732            _ => {},
1733        }
1734    }
1735    if quoted || angled {
1736        return None;
1737    }
1738    parts.push(&value[start..]);
1739    Some(parts)
1740}
1741
1742#[cfg(feature = "wordpress-review")]
1743fn split_http_link_parameters(value: &str) -> Option<Vec<&str>> {
1744    let mut parts = Vec::new();
1745    let mut start = 0_usize;
1746    let mut quoted = false;
1747    for (index, byte) in value.bytes().enumerate() {
1748        match byte {
1749            b'"' => quoted = !quoted,
1750            b';' if !quoted => {
1751                parts.push(&value[start..index]);
1752                start = index.saturating_add(1);
1753            },
1754            b'\\' | 0..=31 | 127 => return None,
1755            _ => {},
1756        }
1757    }
1758    if quoted {
1759        return None;
1760    }
1761    parts.push(&value[start..]);
1762    Some(parts)
1763}
1764
1765#[cfg(feature = "wordpress-review")]
1766fn admitted_wordpress_rest_index(document_url: &Url, reference: &str) -> Option<Url> {
1767    if !wordpress_reference_path_is_safe(reference) {
1768        return None;
1769    }
1770    let candidate = document_url.join(reference).ok()?;
1771    if !matches!(candidate.scheme(), "http" | "https")
1772        || !candidate.username().is_empty()
1773        || candidate.password().is_some()
1774        || candidate.fragment().is_some()
1775        || candidate.origin() != document_url.origin()
1776    {
1777        return None;
1778    }
1779    let path_segments = candidate.path_segments()?.collect::<Vec<_>>();
1780    if candidate.query().is_none() && matches!(path_segments.as_slice(), [.., "wp-json", ""]) {
1781        return Some(candidate);
1782    }
1783    if !candidate.path().ends_with('/') && !candidate.path().ends_with("/index.php") {
1784        return None;
1785    }
1786    let raw_query = candidate.query()?;
1787    wordpress_rest_route_query_is_admitted(raw_query).then_some(candidate)
1788}
1789
1790/// Admits only the exact documented root route spelling and one deliberately
1791/// supported percent-encoded slash. Decoding the key first would incorrectly
1792/// turn alternate raw spellings into request authority.
1793#[cfg(feature = "wordpress-review")]
1794pub(crate) fn wordpress_rest_route_query_is_admitted(raw_query: &str) -> bool {
1795    matches!(raw_query, "rest_route=/" | "rest_route=%2F")
1796}
1797
1798#[cfg(feature = "wordpress-review")]
1799pub(crate) fn wordpress_reference_path_is_safe(reference: &str) -> bool {
1800    if reference.is_empty()
1801        || !reference.is_ascii()
1802        || reference.trim() != reference
1803        || reference.chars().any(|character| {
1804            character == '\\' || character.is_control() || character.is_whitespace()
1805        })
1806        || !wordpress_reference_authority_is_unambiguous(reference)
1807    {
1808        return false;
1809    }
1810    let without_fragment = reference
1811        .split_once('#')
1812        .map_or(reference, |(path, _)| path);
1813    let raw_path = without_fragment
1814        .split_once('?')
1815        .map_or(without_fragment, |(path, _)| path);
1816    if raw_path
1817        .split('/')
1818        .any(|segment| matches!(segment, "." | ".."))
1819    {
1820        return false;
1821    }
1822    let lowercase = raw_path.to_ascii_lowercase();
1823    !["%2e", "%2f", "%5c", "%25"]
1824        .iter()
1825        .any(|encoded| lowercase.contains(encoded))
1826}
1827
1828#[cfg(feature = "wordpress-review")]
1829fn wordpress_reference_authority_is_unambiguous(reference: &str) -> bool {
1830    if let Some(authority_and_path) = reference.strip_prefix("http://") {
1831        return raw_reference_authority_has_path(Some(authority_and_path))
1832            && Url::parse(reference).is_ok_and(|parsed| parsed.as_str() == reference);
1833    }
1834    if let Some(authority_and_path) = reference.strip_prefix("https://") {
1835        return raw_reference_authority_has_path(Some(authority_and_path))
1836            && Url::parse(reference).is_ok_and(|parsed| parsed.as_str() == reference);
1837    }
1838    if reference.starts_with("//") {
1839        return raw_reference_authority_has_path(reference.get(2..))
1840            && Url::parse("https://reference.invalid/")
1841                .ok()
1842                .and_then(|base| base.join(reference).ok())
1843                .is_some_and(|parsed| parsed.as_str().strip_prefix("https:") == Some(reference));
1844    }
1845    let first_delimiter = reference.find(['/', '?', '#']).unwrap_or(reference.len());
1846    !reference[..first_delimiter].contains(':')
1847}
1848
1849#[cfg(feature = "wordpress-review")]
1850fn raw_reference_authority_has_path(authority_and_path: Option<&str>) -> bool {
1851    let Some(authority_and_path) = authority_and_path else {
1852        return false;
1853    };
1854    let Some(path_offset) = authority_and_path.find('/') else {
1855        return false;
1856    };
1857    let authority = &authority_and_path[..path_offset];
1858    !authority.is_empty()
1859        && !authority
1860            .chars()
1861            .any(|character| matches!(character, '@' | '?' | '#'))
1862}
1863
1864fn response_cookie_names(headers: &HeaderMap) -> BTreeSet<String> {
1865    headers
1866        .get_all("set-cookie")
1867        .iter()
1868        .filter_map(|value| value.to_str().ok())
1869        .filter_map(|value| value.split(';').next())
1870        .filter_map(|pair| pair.split_once('=').map(|(name, _)| name.trim()))
1871        .filter(|name| valid_cookie_name(name))
1872        .map(str::to_owned)
1873        .collect()
1874}
1875
1876fn valid_cookie_name(name: &str) -> bool {
1877    !name.is_empty()
1878        && name.bytes().all(|byte| {
1879            byte.is_ascii()
1880                && !byte.is_ascii_control()
1881                && !matches!(
1882                    byte,
1883                    b' ' | b'\t'
1884                        | b'('
1885                        | b')'
1886                        | b'<'
1887                        | b'>'
1888                        | b'@'
1889                        | b','
1890                        | b';'
1891                        | b':'
1892                        | b'\\'
1893                        | b'"'
1894                        | b'/'
1895                        | b'['
1896                        | b']'
1897                        | b'?'
1898                        | b'='
1899                        | b'{'
1900                        | b'}'
1901                )
1902        })
1903}
1904
1905/// Stable identity of the bounded HTML form-control-name extraction, cited as
1906/// the derivation algorithm of the derived form-control evidence.
1907fn form_control_derivation_algorithm() -> DerivationAlgorithm {
1908    DerivationAlgorithm::new("http.form-control-names", 1)
1909        .expect("static form-control derivation algorithm identity is valid")
1910}
1911
1912fn textual_response(headers: &HeaderMap) -> bool {
1913    joined_header(headers, "content-type")
1914        .map(|content_type| {
1915            let content_type = content_type.to_ascii_lowercase();
1916            content_type.starts_with("text/")
1917                || content_type.contains("json")
1918                || content_type.contains("xml")
1919                || content_type.contains("javascript")
1920                || content_type.contains("x-www-form-urlencoded")
1921        })
1922        .unwrap_or(false)
1923}
1924
1925pub(crate) fn normalized_media_type(headers: &HeaderMap) -> Option<String> {
1926    let mut values = headers.get_all("content-type").iter();
1927    let raw = values.next()?.to_str().ok()?;
1928    if values.next().is_some() {
1929        return None;
1930    }
1931    let essence = raw.split(';').next()?.trim();
1932    let (top_level, subtype) = essence.split_once('/')?;
1933    if top_level.is_empty()
1934        || subtype.is_empty()
1935        || subtype.contains('/')
1936        || !top_level.bytes().all(http_token_byte)
1937        || !subtype.bytes().all(http_token_byte)
1938    {
1939        return None;
1940    }
1941    Some(format!(
1942        "{}/{}",
1943        top_level.to_ascii_lowercase(),
1944        subtype.to_ascii_lowercase()
1945    ))
1946}
1947
1948pub(crate) fn json_compatible_media_type(media_type: &str) -> bool {
1949    media_type
1950        .split_once('/')
1951        .is_some_and(|(_, subtype)| subtype == "json" || subtype.ends_with("+json"))
1952}
1953
1954fn http_token_byte(byte: u8) -> bool {
1955    byte.is_ascii_alphanumeric()
1956        || matches!(
1957            byte,
1958            b'!' | b'#'
1959                | b'$'
1960                | b'%'
1961                | b'&'
1962                | b'\''
1963                | b'*'
1964                | b'+'
1965                | b'-'
1966                | b'.'
1967                | b'^'
1968                | b'_'
1969                | b'`'
1970                | b'|'
1971                | b'~'
1972        )
1973}
1974
1975fn append_rate_limit_evidence(
1976    executor: &HttpEvidenceExecutor,
1977    decision: &DecisionExecutionRequest,
1978    response: &CollectedHttpResponse,
1979    evidence: &mut Vec<Evidence>,
1980) -> Result<(), HttpEvidenceError> {
1981    let rate_headers = [
1982        (
1983            "retry-after",
1984            None,
1985            HttpEvidencePredicate::RATE_LIMIT_RETRY_AFTER,
1986        ),
1987        (
1988            "ratelimit-limit",
1989            Some("x-ratelimit-limit"),
1990            HttpEvidencePredicate::RATE_LIMIT_LIMIT,
1991        ),
1992        (
1993            "ratelimit-remaining",
1994            Some("x-ratelimit-remaining"),
1995            HttpEvidencePredicate::RATE_LIMIT_REMAINING,
1996        ),
1997        (
1998            "ratelimit-reset",
1999            Some("x-ratelimit-reset"),
2000            HttpEvidencePredicate::RATE_LIMIT_RESET,
2001        ),
2002    ];
2003    let advertised = rate_headers.iter().any(|(standard, fallback, _)| {
2004        response.headers.contains_key(*standard)
2005            || fallback.is_some_and(|header| response.headers.contains_key(header))
2006    });
2007
2008    evidence.push(executor.observation(
2009        decision,
2010        EvidenceKind::RateLimit,
2011        HttpEvidencePredicate::RATE_LIMIT_DETECTED.into(),
2012        EvidenceValue::Boolean(response.status == StatusCode::TOO_MANY_REQUESTS),
2013        "rate-limit-status",
2014    )?);
2015    evidence.push(executor.observation(
2016        decision,
2017        EvidenceKind::RateLimit,
2018        HttpEvidencePredicate::RATE_LIMIT_ADVERTISED.into(),
2019        EvidenceValue::Boolean(advertised),
2020        "rate-limit-headers",
2021    )?);
2022
2023    for (standard, fallback, predicate) in rate_headers {
2024        let selected = joined_header(&response.headers, standard).map(|value| (standard, value));
2025        let selected = selected.or_else(|| {
2026            fallback.and_then(|header| {
2027                joined_header(&response.headers, header).map(|value| (header, value))
2028            })
2029        });
2030        let Some((header, raw)) = selected else {
2031            continue;
2032        };
2033        let value = match raw.parse::<u64>() {
2034            Ok(value) => EvidenceValue::Unsigned(value),
2035            Err(_) if executor.policy().captured_headers().contains(header) => {
2036                EvidenceValue::Text(raw)
2037            },
2038            Err(_) => continue,
2039        };
2040        evidence.push(executor.observation(
2041            decision,
2042            EvidenceKind::RateLimit,
2043            predicate.into(),
2044            value,
2045            &format!("rate-limit-header:{header}"),
2046        )?);
2047    }
2048    Ok(())
2049}
2050
2051/// Projects one already-collected fixed GraphQL POST into the same bounded HTTP
2052/// and defense evidence vocabulary used by the standard executor.
2053///
2054/// The caller supplies only its closed GraphQL classification observations;
2055/// raw response bytes and headers remain inside this module. The decision
2056/// runner still validates and atomically commits the complete returned batch.
2057#[cfg(feature = "graphql-review")]
2058pub(crate) fn project_graphql_transport_response(
2059    executor_id: &str,
2060    decision: &DecisionExecutionRequest,
2061    requested_url: &Url,
2062    response: CollectedHttpResponse,
2063    reliability: ConfidenceScore,
2064    privacy: crate::web_runtime::GraphqlResponsePrivacy,
2065    graphql_projection: (Vec<Evidence>, Evidence),
2066) -> Result<Vec<Evidence>, HttpEvidenceError> {
2067    let (mut graphql_observations, graphql_classification) = graphql_projection;
2068    if response.final_url != *requested_url {
2069        return Err(HttpEvidenceError::AssessmentObserverInvariant {
2070            invariant: "GraphQL transport changed the exact requested target",
2071        });
2072    }
2073    let source = |method: &str| {
2074        EvidenceSource::new(executor_id, method)
2075            .and_then(|source| source.with_correlation_id(decision.case().id()))
2076    };
2077    let observation = |kind, predicate, value, method: &str| {
2078        Ok::<_, HttpEvidenceError>(Evidence::new(
2079            decision.case().subject().clone(),
2080            kind,
2081            predicate,
2082            value,
2083            source(method)?,
2084            reliability,
2085        ))
2086    };
2087    let media_type = normalized_media_type(&response.headers);
2088    let rate_limit_advertised = [
2089        "retry-after",
2090        "ratelimit-limit",
2091        "ratelimit-remaining",
2092        "ratelimit-reset",
2093        "x-ratelimit-limit",
2094        "x-ratelimit-remaining",
2095        "x-ratelimit-reset",
2096    ]
2097    .into_iter()
2098    .any(|name| response.headers.contains_key(name));
2099    let mut evidence = vec![
2100        observation(
2101            EvidenceKind::Http,
2102            HttpEvidencePredicate::REQUEST_METHOD.into(),
2103            EvidenceValue::Text("POST".to_owned()),
2104            "request-method",
2105        )?,
2106        observation(
2107            EvidenceKind::Http,
2108            HttpEvidencePredicate::REQUEST_URL.into(),
2109            EvidenceValue::Text(requested_url.to_string()),
2110            "request-url",
2111        )?,
2112        observation(
2113            EvidenceKind::Http,
2114            HttpEvidencePredicate::RESPONSE_STATUS.into(),
2115            EvidenceValue::Unsigned(u64::from(response.status.as_u16())),
2116            "response-status",
2117        )?,
2118        observation(
2119            EvidenceKind::Http,
2120            HttpEvidencePredicate::RESPONSE_FINAL_URL.into(),
2121            EvidenceValue::Text(response.final_url.to_string()),
2122            "response-final-url",
2123        )?,
2124        observation(
2125            EvidenceKind::Http,
2126            HttpEvidencePredicate::RESPONSE_VERSION.into(),
2127            EvidenceValue::Text(response.version.clone()),
2128            "response-version",
2129        )?,
2130        observation(
2131            EvidenceKind::Timing,
2132            HttpEvidencePredicate::TIMING_TTFB_MS.into(),
2133            EvidenceValue::Unsigned(response.ttfb_ms),
2134            "time-to-first-byte",
2135        )?,
2136        observation(
2137            EvidenceKind::Timing,
2138            HttpEvidencePredicate::TIMING_TOTAL_MS.into(),
2139            EvidenceValue::Unsigned(response.total_ms),
2140            "total-response-time",
2141        )?,
2142        observation(
2143            EvidenceKind::Content,
2144            HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into(),
2145            EvidenceValue::Unsigned(u64::try_from(response.body.len()).unwrap_or(u64::MAX)),
2146            "response-body-size",
2147        )?,
2148        observation(
2149            EvidenceKind::Content,
2150            HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into(),
2151            EvidenceValue::Boolean(response.body_truncated),
2152            "response-body-truncation",
2153        )?,
2154        observation(
2155            EvidenceKind::RateLimit,
2156            HttpEvidencePredicate::RATE_LIMIT_DETECTED.into(),
2157            EvidenceValue::Boolean(response.status == StatusCode::TOO_MANY_REQUESTS),
2158            "rate-limit-status",
2159        )?,
2160        observation(
2161            EvidenceKind::RateLimit,
2162            HttpEvidencePredicate::RATE_LIMIT_ADVERTISED.into(),
2163            EvidenceValue::Boolean(rate_limit_advertised),
2164            "rate-limit-headers",
2165        )?,
2166    ];
2167    if let Some(media_type) = media_type {
2168        evidence.push(observation(
2169            EvidenceKind::Http,
2170            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into(),
2171            EvidenceValue::Text(media_type.clone()),
2172            "response-media-type",
2173        )?);
2174        evidence.push(observation(
2175            EvidenceKind::Http,
2176            HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into(),
2177            EvidenceValue::Boolean(json_compatible_media_type(&media_type)),
2178            "response-media-type-json-compatibility",
2179        )?);
2180    }
2181    let body_parent = match privacy {
2182        crate::web_runtime::GraphqlResponsePrivacy::HistoricalDigests => {
2183            let digest = observation(
2184                EvidenceKind::Content,
2185                HttpEvidencePredicate::RESPONSE_BODY_SHA256.into(),
2186                EvidenceValue::Text(format!("{:x}", Sha256::digest(&response.body))),
2187                "response-body-sha256",
2188            )?;
2189            let id = digest.id().clone();
2190            // Option-off evidence ordering and identities are unchanged.
2191            evidence.insert(9, digest);
2192            id
2193        },
2194        #[cfg(feature = "secret-exposure-review")]
2195        crate::web_runtime::GraphqlResponsePrivacy::ValueFree => {
2196            let lineage = crate::web_runtime::project_graphql_body_privacy_lineage(
2197                decision,
2198                &evidence,
2199                executor_id,
2200                reliability,
2201            )?;
2202            let id = lineage.id().clone();
2203            evidence.push(lineage);
2204            id
2205        },
2206    };
2207    let classification_predicate =
2208        KnowledgePredicate::new("web.graphql.transport", "classification")?;
2209    if graphql_observations
2210        .iter()
2211        .chain(std::iter::once(&graphql_classification))
2212        .any(|item| {
2213            item.subject() != decision.case().subject()
2214                || item.source().component() != executor_id
2215                || item.source().correlation_id() != Some(decision.case().id())
2216                || item.reliability() != reliability
2217                || !item.origin().is_direct()
2218        })
2219        || graphql_classification.predicate() != &classification_predicate
2220        || graphql_observations
2221            .iter()
2222            .any(|item| item.predicate() == &classification_predicate)
2223    {
2224        return Err(HttpEvidenceError::AssessmentObserverInvariant {
2225            invariant: "GraphQL observations must preserve executor provenance",
2226        });
2227    }
2228    let graphql_parent_ids = graphql_observations
2229        .iter()
2230        .map(|item| item.id().clone())
2231        .collect::<Vec<_>>();
2232    evidence.append(&mut graphql_observations);
2233
2234    let classification_http_parents = [
2235        HttpEvidencePredicate::REQUEST_URL,
2236        HttpEvidencePredicate::RESPONSE_STATUS,
2237        HttpEvidencePredicate::RESPONSE_FINAL_URL,
2238        HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
2239    ];
2240    let mut classification_parents = graphql_parent_ids;
2241    classification_parents.push(body_parent.clone());
2242    for descriptor in classification_http_parents {
2243        let predicate = descriptor.into_knowledge();
2244        let mut matching = evidence
2245            .iter()
2246            .filter(|item| item.predicate() == &predicate);
2247        let Some(parent) = matching.next() else {
2248            return Err(HttpEvidenceError::AssessmentObserverInvariant {
2249                invariant: "GraphQL classification is missing transport provenance",
2250            });
2251        };
2252        if matching.next().is_some() {
2253            return Err(HttpEvidenceError::AssessmentObserverInvariant {
2254                invariant: "GraphQL classification transport provenance is ambiguous",
2255            });
2256        }
2257        classification_parents.push(parent.id().clone());
2258    }
2259    let classification_derivation = EvidenceDerivation::new(
2260        classification_parents,
2261        DerivationAlgorithm::new("web.graphql.transport-classification", 1)?,
2262    )?;
2263    evidence.push(graphql_classification.derived_from(classification_derivation));
2264
2265    // POST expects a response body, so its bounded defense body semantics are
2266    // identical to the GET branch; no request-method behavior is inferred.
2267    let signal = bounded_assessment_defense_signal(
2268        response.status.as_u16(),
2269        HttpProbeMethod::Get,
2270        &response.headers,
2271        response.body_complete,
2272        &response.body,
2273    );
2274    let parent_predicates = [
2275        HttpEvidencePredicate::REQUEST_METHOD,
2276        HttpEvidencePredicate::REQUEST_URL,
2277        HttpEvidencePredicate::RESPONSE_STATUS,
2278        HttpEvidencePredicate::RESPONSE_FINAL_URL,
2279        HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
2280        HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
2281        HttpEvidencePredicate::RATE_LIMIT_DETECTED,
2282        HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
2283    ];
2284    let mut parents = Vec::with_capacity(parent_predicates.len());
2285    parents.push(body_parent);
2286    for descriptor in parent_predicates {
2287        let predicate = descriptor.into_knowledge();
2288        let mut matching = evidence
2289            .iter()
2290            .filter(|item| item.predicate() == &predicate);
2291        let Some(parent) = matching.next() else {
2292            return Err(HttpEvidenceError::AssessmentDefenseProjectionInvariant);
2293        };
2294        if matching.next().is_some() {
2295            return Err(HttpEvidenceError::AssessmentDefenseProjectionInvariant);
2296        }
2297        parents.push(parent.id().clone());
2298    }
2299    evidence.extend(project_assessment_defense_signal(
2300        &signal,
2301        AssessmentDefenseProjectionContext {
2302            subject: decision.case().subject(),
2303            case_id: decision.case().id(),
2304            executor_id,
2305            reliability,
2306            parents,
2307        },
2308    )?);
2309    Ok(evidence)
2310}
2311
2312/// Reduces raw transport material to a closed, bounded signal vocabulary.
2313/// Returned header values are fixed literals; no response value or cookie
2314/// value is retained, hashed, or exposed to the assessment ledger.
2315pub(crate) fn bounded_assessment_defense_signal(
2316    status: u16,
2317    method: HttpProbeMethod,
2318    headers: &HeaderMap,
2319    body_complete: bool,
2320    body: &[u8],
2321) -> AssessmentDefenseSignal {
2322    let mut safe_headers = Vec::<(String, String)>::new();
2323    let mut input_limit_reached = body.len() > MAX_FINGERPRINT_BODY_SCAN_BYTES;
2324
2325    for name in [
2326        "cf-ray",
2327        "x-amzn-waf-action",
2328        "x-amzn-requestid",
2329        "x-iinfo",
2330        "x-sucuri-id",
2331        "retry-after",
2332        "ratelimit-limit",
2333        "ratelimit-remaining",
2334        "ratelimit-reset",
2335        "x-ratelimit-limit",
2336        "x-ratelimit-remaining",
2337        "x-ratelimit-reset",
2338    ] {
2339        let count = headers
2340            .get_all(name)
2341            .iter()
2342            .take(MAX_ASSESSMENT_DEFENSE_HEADER_OCCURRENCES + 1)
2343            .count();
2344        if count > MAX_ASSESSMENT_DEFENSE_HEADER_OCCURRENCES {
2345            input_limit_reached = true;
2346        }
2347        if count > 0 {
2348            safe_headers.push((name.to_owned(), "present".to_owned()));
2349        }
2350    }
2351
2352    let server_values: Vec<_> = headers
2353        .get_all("server")
2354        .iter()
2355        .take(MAX_ASSESSMENT_DEFENSE_HEADER_OCCURRENCES + 1)
2356        .collect();
2357    if server_values.len() > MAX_ASSESSMENT_DEFENSE_HEADER_OCCURRENCES {
2358        input_limit_reached = true;
2359    }
2360    for value in server_values
2361        .into_iter()
2362        .take(MAX_ASSESSMENT_DEFENSE_HEADER_OCCURRENCES)
2363    {
2364        let bytes = value.as_bytes();
2365        if bytes.len() > MAX_ASSESSMENT_DEFENSE_HEADER_VALUE_BYTES {
2366            input_limit_reached = true;
2367        }
2368        let prefix = &bytes[..bytes.len().min(MAX_ASSESSMENT_DEFENSE_HEADER_VALUE_BYTES)];
2369        for needle in ["cloudflare", "mod_security", "akamaighost"] {
2370            if ascii_contains_ignore_case(prefix, needle.as_bytes()) {
2371                safe_headers.push(("server".to_owned(), needle.to_owned()));
2372            }
2373        }
2374    }
2375
2376    let cookie_values: Vec<_> = headers
2377        .get_all("set-cookie")
2378        .iter()
2379        .take(MAX_ASSESSMENT_DEFENSE_HEADER_OCCURRENCES + 1)
2380        .collect();
2381    if cookie_values.len() > MAX_ASSESSMENT_DEFENSE_HEADER_OCCURRENCES {
2382        input_limit_reached = true;
2383    }
2384    for value in cookie_values
2385        .into_iter()
2386        .take(MAX_ASSESSMENT_DEFENSE_HEADER_OCCURRENCES)
2387    {
2388        let bytes = value.as_bytes();
2389        let Some(separator) = bytes
2390            .iter()
2391            .take(MAX_ASSESSMENT_DEFENSE_COOKIE_NAME_BYTES + 1)
2392            .position(|byte| *byte == b'=')
2393        else {
2394            input_limit_reached = true;
2395            continue;
2396        };
2397        if separator == 0 || separator > MAX_ASSESSMENT_DEFENSE_COOKIE_NAME_BYTES {
2398            input_limit_reached = true;
2399            continue;
2400        }
2401        let name = &bytes[..separator];
2402        for recognized in [
2403            "incap_ses",
2404            "bigipserver",
2405            "barra_counter_session",
2406            "fortiwafsid",
2407        ] {
2408            if ascii_starts_with_ignore_case(name, recognized.as_bytes()) {
2409                safe_headers.push(("set-cookie".to_owned(), recognized.to_owned()));
2410            }
2411        }
2412    }
2413
2414    safe_headers.sort();
2415    safe_headers.dedup();
2416    let safe_header_refs: Vec<_> = safe_headers
2417        .iter()
2418        .map(|(name, value)| (name.as_str(), value.as_str()))
2419        .collect();
2420
2421    let (body_text, body_coverage) = if method == HttpProbeMethod::Head || !body_complete {
2422        ("", AssessmentDefenseBodyCoverage::MetadataOnly)
2423    } else if body.len() <= MAX_FINGERPRINT_BODY_SCAN_BYTES {
2424        match std::str::from_utf8(body) {
2425            Ok(text) => (text, AssessmentDefenseBodyCoverage::CompleteUtf8Prefix),
2426            Err(_) => {
2427                input_limit_reached = true;
2428                ("", AssessmentDefenseBodyCoverage::MetadataOnly)
2429            },
2430        }
2431    } else {
2432        input_limit_reached = true;
2433        let prefix = &body[..MAX_FINGERPRINT_BODY_SCAN_BYTES];
2434        match std::str::from_utf8(prefix) {
2435            Ok(text) => (text, AssessmentDefenseBodyCoverage::CompleteUtf8Prefix),
2436            Err(error) if error.error_len().is_none() => {
2437                let valid = &prefix[..error.valid_up_to()];
2438                (
2439                    std::str::from_utf8(valid).unwrap_or(""),
2440                    AssessmentDefenseBodyCoverage::CompleteUtf8Prefix,
2441                )
2442            },
2443            Err(_) => ("", AssessmentDefenseBodyCoverage::MetadataOnly),
2444        }
2445    };
2446
2447    AssessmentDefenseSignal::new(
2448        DefenseState::observe(status, &safe_header_refs, body_text),
2449        body_coverage,
2450        input_limit_reached,
2451    )
2452}
2453
2454fn ascii_contains_ignore_case(haystack: &[u8], needle: &[u8]) -> bool {
2455    haystack.windows(needle.len()).any(|window| {
2456        window
2457            .iter()
2458            .zip(needle)
2459            .all(|(left, right)| left.eq_ignore_ascii_case(right))
2460    })
2461}
2462
2463fn ascii_starts_with_ignore_case(value: &[u8], prefix: &[u8]) -> bool {
2464    value.len() >= prefix.len()
2465        && value[..prefix.len()]
2466            .iter()
2467            .zip(prefix)
2468            .all(|(left, right)| left.eq_ignore_ascii_case(right))
2469}
2470
2471fn elapsed_ms(duration: Duration) -> u64 {
2472    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
2473}
2474
2475#[cfg(test)]
2476mod tests {
2477    use std::sync::{
2478        atomic::{AtomicUsize, Ordering},
2479        Arc,
2480    };
2481
2482    use termivar_core::{EntityId, EvidenceValue, HypothesisStrength};
2483    use tokio::io::{AsyncReadExt, AsyncWriteExt};
2484    use tokio::net::TcpListener;
2485    use tokio::sync::oneshot;
2486
2487    use super::*;
2488    use crate::{
2489        DecisionActionOrigin, DecisionExecutionStage, DecisionExecutorRegistry,
2490        DecisionLoopCommand, DecisionRunnerAdapter, KnowledgeBase, RuleEngine, RuntimeBudget,
2491        RuntimeBudgetDimension, StandardWebReasoning, TransportDispatchOutcome, VerificationCase,
2492    };
2493
2494    pub(super) struct RejectingCompleteResponseObserver;
2495
2496    impl CompleteHttpResponseObserver for RejectingCompleteResponseObserver {
2497        fn observe(
2498            &self,
2499            _observation: CompleteHttpResponseObservation<'_>,
2500        ) -> Result<(Vec<Evidence>, ResponseBodyDigestDisposition), HttpEvidenceError> {
2501            Err(HttpEvidenceError::AssessmentObserverInvariant {
2502                invariant: "test-observer-rejected",
2503            })
2504        }
2505    }
2506
2507    #[test]
2508    fn assessment_defense_metadata_only_never_projects_open() {
2509        let signal = bounded_assessment_defense_signal(
2510            200,
2511            HttpProbeMethod::Head,
2512            &HeaderMap::new(),
2513            true,
2514            b"attention required",
2515        );
2516        assert_eq!(
2517            signal.body_coverage(),
2518            AssessmentDefenseBodyCoverage::MetadataOnly
2519        );
2520        assert_eq!(
2521            signal.state().posture(),
2522            crate::defense::DefensePosture::Open
2523        );
2524        assert!(!signal.has_positive_metadata_signal());
2525    }
2526
2527    #[test]
2528    fn assessment_defense_reset_only_is_a_positive_rate_signal() {
2529        let mut headers = HeaderMap::new();
2530        headers.insert("ratelimit-reset", HeaderValue::from_static("60"));
2531        let signal =
2532            bounded_assessment_defense_signal(200, HttpProbeMethod::Get, &headers, true, b"ok");
2533        assert!(signal.state().has_rate_limit_headers());
2534        assert!(signal.state().is_rate_limited());
2535        assert!(signal.has_positive_metadata_signal());
2536    }
2537
2538    #[test]
2539    fn incomplete_large_body_records_the_detector_input_limit() {
2540        let body = vec![b'a'; MAX_FINGERPRINT_BODY_SCAN_BYTES + 1];
2541        let signal = bounded_assessment_defense_signal(
2542            200,
2543            HttpProbeMethod::Get,
2544            &HeaderMap::new(),
2545            false,
2546            &body,
2547        );
2548        assert_eq!(
2549            signal.body_coverage(),
2550            AssessmentDefenseBodyCoverage::MetadataOnly
2551        );
2552        assert!(signal.input_limit_reached());
2553        assert!(!signal.has_positive_metadata_signal());
2554    }
2555
2556    #[test]
2557    fn standard_http_executor_keeps_assessment_projection_disabled() {
2558        let target = Url::parse("https://example.test/").unwrap();
2559        let policy = HttpEvidencePolicy::for_origin(target).unwrap();
2560        let requests = HttpRequestBroker::new_unmetered(policy).unwrap();
2561        let executor = HttpEvidenceExecutor::new_with_request_broker(
2562            requests,
2563            Arc::new(SubjectHttpProbeProvider::new(HttpProbeMethod::Get)),
2564        )
2565        .unwrap();
2566        assert!(!executor.assessment_defense_projection);
2567    }
2568
2569    fn observed_form_control_names(sample: &str) -> Vec<String> {
2570        match extract_form_control_names(sample) {
2571            FormControlExtraction::Observed(names) => names,
2572            FormControlExtraction::SampleTooLarge => {
2573                panic!("test sample unexpectedly exceeded the form-control parse boundary")
2574            },
2575        }
2576    }
2577
2578    #[test]
2579    fn form_control_extraction_reads_named_controls_sorted_and_deduplicated() {
2580        let names = observed_form_control_names(
2581            "<form>\
2582             <input name=\"username\">\
2583             <select name=\"country\"><option>x</option></select>\
2584             <textarea name=\"comment\"></textarea>\
2585             <input name=\"username\">\
2586             <input name='remember'>\
2587             </form>",
2588        );
2589        // Sorted, deduplicated, single- and double-quoted both read.
2590        assert_eq!(names, ["comment", "country", "remember", "username"]);
2591    }
2592
2593    #[test]
2594    fn form_control_extraction_ignores_unnamed_or_valueless_names() {
2595        let names = observed_form_control_names(
2596            "<input type=\"submit\"><input name=\"\"><button name=\"go\">",
2597        );
2598        // Nameless/empty-name controls are skipped; button is not a target element.
2599        assert!(names.is_empty());
2600    }
2601
2602    #[test]
2603    fn form_control_extraction_never_treats_commented_or_scripted_markup_as_controls() {
2604        let names = observed_form_control_names(
2605            "<!-- <input name=\"fake_comment\"> -->\
2606             <script>const x = '<input name=\"fake_script\">';</script>\
2607             <style>input[name=\"fake_style\"] { color: red; }</style>\
2608             <input name=\"real\">",
2609        );
2610        assert_eq!(names, ["real"]);
2611    }
2612
2613    #[test]
2614    fn form_control_extraction_keeps_textarea_name_but_drops_its_body() {
2615        let names = observed_form_control_names(
2616            "<textarea name=\"real\">\n  <input name=\"fake_in_textarea\">\n</textarea>",
2617        );
2618        // The textarea's own name is a control; markup inside its body is text.
2619        assert_eq!(names, ["real"]);
2620    }
2621
2622    #[test]
2623    fn form_control_extraction_records_names_never_values() {
2624        let names = observed_form_control_names(
2625            "<input type=\"hidden\" name=\"_token\" value=\"SUPER_SECRET_CSRF\">\
2626             <input type=\"password\" name=\"password\" value=\"hunter2\">",
2627        );
2628        assert_eq!(names, ["_token", "password"]);
2629        // The captured set must never leak any control value.
2630        assert!(!names.iter().any(|name| name.contains("SUPER_SECRET_CSRF")));
2631        assert!(!names.iter().any(|name| name.contains("hunter2")));
2632    }
2633
2634    #[test]
2635    fn form_control_extraction_does_not_confuse_suffix_attributes_with_name() {
2636        // `data-name` / `formname` are not the `name` attribute and must not leak.
2637        let names = observed_form_control_names(
2638            "<input data-name=\"nickname\" formname=\"x\" name=\"real\">",
2639        );
2640        assert_eq!(names, ["real"]);
2641    }
2642
2643    #[test]
2644    fn form_control_extraction_respects_attribute_quote_state() {
2645        // A `name=` sequence inside another attribute's quoted value is text, not
2646        // a real attribute — the tokenizer tracks quote state so it is never read.
2647        assert!(observed_form_control_names("<input title=\" name='fake'\">").is_empty());
2648        assert!(observed_form_control_names("<input title=' name=\"fake\"'>").is_empty());
2649        assert_eq!(
2650            observed_form_control_names("<input title=\"ordinary\" name=\"real\">"),
2651            ["real"]
2652        );
2653    }
2654
2655    #[test]
2656    fn form_control_extraction_preserves_name_attribute_whitespace() {
2657        let names = observed_form_control_names(
2658            "<input name=\" _token \"><input name=\" \"><input name=\"\">",
2659        );
2660
2661        // HTML form-control names are attribute values, not space-separated
2662        // tokens. Preserve non-empty values exactly so a padded convention name
2663        // cannot be promoted into an exact `_token` observation.
2664        assert_eq!(names, [" ", " _token "]);
2665        assert!(!names.iter().any(|name| name == "_token"));
2666    }
2667
2668    #[test]
2669    fn form_control_extraction_rejects_foreign_namespace_lookalikes() {
2670        let names = observed_form_control_names(
2671            "<svg><input name=\"svg-input\"></input>\
2672                   <select name=\"svg-select\"></select>\
2673                   <textarea name=\"svg-textarea\"></textarea></svg>\
2674             <math><input name=\"math-input\"></input>\
2675                    <select name=\"math-select\"></select>\
2676                    <textarea name=\"math-textarea\"></textarea></math>",
2677        );
2678
2679        // SVG/MathML elements can share an HTML control's local name, but they
2680        // are not HTML form controls and must not manufacture PHP/convention
2681        // evidence.
2682        assert_eq!(names, Vec::<String>::new());
2683    }
2684
2685    #[test]
2686    fn form_control_extraction_observes_html_integration_points_only() {
2687        // HTML integration points return descendants to the HTML namespace.
2688        // Re-entering SVG makes the nested lookalike foreign again.
2689        assert_eq!(
2690            observed_form_control_names(
2691                "<svg><foreignObject><input name=\"foreign-object\">\
2692                      <svg><input name=\"nested-svg\"></svg>\
2693                    </foreignObject></svg>\
2694                 <math><mtext><select name=\"math-text\"></select></mtext>\
2695                   <annotation-xml encoding=\"text/html\">\
2696                     <textarea name=\"annotation\"></textarea>\
2697                   </annotation-xml></math>"
2698            ),
2699            ["annotation", "foreign-object", "math-text"]
2700        );
2701    }
2702
2703    #[test]
2704    fn form_control_extraction_is_stack_safe_near_sixty_four_kibibytes() {
2705        const DEPTH: usize = 5_900;
2706        let mut html = String::with_capacity(DEPTH * 11 + 32);
2707        for _ in 0..DEPTH {
2708            html.push_str("<div>");
2709        }
2710        html.push_str("<input name=\"deep\">");
2711        for _ in 0..DEPTH {
2712            html.push_str("</div>");
2713        }
2714
2715        assert!((63 * 1024..=64 * 1024).contains(&html.len()));
2716        assert_eq!(observed_form_control_names(&html), ["deep"]);
2717    }
2718
2719    #[test]
2720    fn form_control_extraction_is_stack_safe_at_compact_nesting_limit() {
2721        use super::form_controls::MAX_FORM_CONTROL_PARSE_BYTES;
2722
2723        let control = "<input name=\"compact-deep\">";
2724        let depth = (MAX_FORM_CONTROL_PARSE_BYTES - control.len()) / 3;
2725        let mut html = "<q>".repeat(depth);
2726        html.push_str(control);
2727        html.push_str(&"x".repeat(MAX_FORM_CONTROL_PARSE_BYTES - html.len()));
2728
2729        assert_eq!(html.len(), MAX_FORM_CONTROL_PARSE_BYTES);
2730        assert_eq!(observed_form_control_names(&html), ["compact-deep"]);
2731    }
2732
2733    #[test]
2734    fn form_control_extraction_accepts_exact_limit_and_rejects_limit_plus_one() {
2735        use super::form_controls::MAX_FORM_CONTROL_PARSE_BYTES;
2736
2737        assert_eq!(MAX_FORM_CONTROL_PARSE_BYTES, 64 * 1024);
2738        let mut exact = "<input name=\"boundary\">".to_owned();
2739        exact.push_str(&"x".repeat(MAX_FORM_CONTROL_PARSE_BYTES - exact.len()));
2740        assert_eq!(exact.len(), MAX_FORM_CONTROL_PARSE_BYTES);
2741        assert_eq!(
2742            extract_form_control_names(&exact),
2743            FormControlExtraction::Observed(vec!["boundary".to_owned()])
2744        );
2745
2746        exact.push('x');
2747        assert_eq!(exact.len(), MAX_FORM_CONTROL_PARSE_BYTES + 1);
2748        assert_eq!(
2749            extract_form_control_names(&exact),
2750            FormControlExtraction::SampleTooLarge,
2751            "an over-limit sample must not be partially parsed"
2752        );
2753    }
2754
2755    struct CountedServer {
2756        target: Url,
2757        requests: Arc<AtomicUsize>,
2758        task: tokio::task::JoinHandle<()>,
2759    }
2760
2761    impl CountedServer {
2762        fn target(&self) -> Url {
2763            self.target.clone()
2764        }
2765
2766        fn requests(&self) -> usize {
2767            self.requests.load(Ordering::SeqCst)
2768        }
2769    }
2770
2771    impl Drop for CountedServer {
2772        fn drop(&mut self) {
2773            self.task.abort();
2774        }
2775    }
2776
2777    struct MultiRequestExecutor {
2778        requests: HttpRequestBroker,
2779        target: Url,
2780    }
2781
2782    struct BufferedRequestExecutor {
2783        requests: HttpRequestBroker,
2784        target: Url,
2785        body: Vec<u8>,
2786    }
2787
2788    #[async_trait]
2789    impl DecisionActionExecutor for MultiRequestExecutor {
2790        fn id(&self) -> &str {
2791            HTTP_EVIDENCE_EXECUTOR_ID
2792        }
2793
2794        async fn execute(
2795            &self,
2796            request: &DecisionExecutionRequest,
2797        ) -> Result<Vec<Evidence>, DecisionExecutorError> {
2798            let probe = HttpProbe::new(self.target.clone(), HttpProbeMethod::Get)
2799                .map_err(into_decision_executor_error)?;
2800            self.requests
2801                .collect(request, &probe)
2802                .await
2803                .map_err(HttpRequestBrokerError::into_decision_executor_error)?;
2804            self.requests
2805                .collect(request, &probe)
2806                .await
2807                .map_err(HttpRequestBrokerError::into_decision_executor_error)?;
2808            Ok(Vec::new())
2809        }
2810    }
2811
2812    #[async_trait]
2813    impl DecisionActionExecutor for BufferedRequestExecutor {
2814        fn id(&self) -> &str {
2815            HTTP_EVIDENCE_EXECUTOR_ID
2816        }
2817
2818        async fn execute(
2819            &self,
2820            request: &DecisionExecutionRequest,
2821        ) -> Result<Vec<Evidence>, DecisionExecutorError> {
2822            let mut buffered = reqwest::Request::new(reqwest::Method::POST, self.target.clone());
2823            *buffered.body_mut() = Some(reqwest::Body::from(self.body.clone()));
2824            self.requests
2825                .collect_buffered_request_for_test(request, buffered)
2826                .await
2827                .map_err(HttpRequestBrokerError::into_decision_executor_error)?;
2828            Ok(Vec::new())
2829        }
2830    }
2831
2832    async fn serve_counted(response: &'static [u8]) -> CountedServer {
2833        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2834        let address = listener.local_addr().unwrap();
2835        let requests = Arc::new(AtomicUsize::new(0));
2836        let counted = requests.clone();
2837        let task = tokio::spawn(async move {
2838            loop {
2839                let (mut stream, _) = listener.accept().await.unwrap();
2840                let mut request = [0_u8; 2048];
2841                let _ = stream.read(&mut request).await.unwrap();
2842                counted.fetch_add(1, Ordering::SeqCst);
2843                stream.write_all(response).await.unwrap();
2844                stream.shutdown().await.unwrap();
2845            }
2846        });
2847        CountedServer {
2848            target: Url::parse(&format!("http://{address}/probe")).unwrap(),
2849            requests,
2850            task,
2851        }
2852    }
2853
2854    async fn serve_empty_response_then_watch_for_retry() -> CountedServer {
2855        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2856        let address = listener.local_addr().unwrap();
2857        let requests = Arc::new(AtomicUsize::new(0));
2858        let counted = requests.clone();
2859        let task = tokio::spawn(async move {
2860            let (mut first, _) = listener.accept().await.unwrap();
2861            let mut request = [0_u8; 2048];
2862            let _ = first.read(&mut request).await.unwrap();
2863            counted.fetch_add(1, Ordering::SeqCst);
2864            drop(first);
2865
2866            if let Ok(Ok((mut retry, _))) =
2867                tokio::time::timeout(Duration::from_millis(250), listener.accept()).await
2868            {
2869                let _ = retry.read(&mut request).await.unwrap();
2870                counted.fetch_add(1, Ordering::SeqCst);
2871            }
2872        });
2873        CountedServer {
2874            target: Url::parse(&format!("http://{address}/probe")).unwrap(),
2875            requests,
2876            task,
2877        }
2878    }
2879
2880    async fn serve_once(response: &'static [u8]) -> Url {
2881        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2882        let address = listener.local_addr().unwrap();
2883        tokio::spawn(async move {
2884            let (mut stream, _) = listener.accept().await.unwrap();
2885            let mut request = [0_u8; 2048];
2886            let _ = stream.read(&mut request).await.unwrap();
2887            stream.write_all(response).await.unwrap();
2888            stream.shutdown().await.unwrap();
2889        });
2890        Url::parse(&format!("http://{address}/probe")).unwrap()
2891    }
2892
2893    async fn serve_partial_then_stall(response_prefix: &'static [u8]) -> Url {
2894        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2895        let address = listener.local_addr().unwrap();
2896        tokio::spawn(async move {
2897            let (mut stream, _) = listener.accept().await.unwrap();
2898            let mut request = [0_u8; 2048];
2899            let _ = stream.read(&mut request).await.unwrap();
2900            stream.write_all(response_prefix).await.unwrap();
2901            stream.flush().await.unwrap();
2902            std::future::pending::<()>().await;
2903        });
2904        Url::parse(&format!("http://{address}/probe")).unwrap()
2905    }
2906
2907    async fn serve_split_body_after_release() -> (Url, oneshot::Receiver<()>, oneshot::Sender<()>) {
2908        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2909        let address = listener.local_addr().unwrap();
2910        let (first_sent, first_received) = oneshot::channel();
2911        let (release, released) = oneshot::channel();
2912        tokio::spawn(async move {
2913            let (mut stream, _) = listener.accept().await.unwrap();
2914            let mut request = [0_u8; 2048];
2915            let _ = stream.read(&mut request).await.unwrap();
2916            stream
2917                .write_all(
2918                    b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 8\r\nConnection: close\r\n\r\n0123",
2919                )
2920                .await
2921                .unwrap();
2922            stream.flush().await.unwrap();
2923            let _ = first_sent.send(());
2924            let _ = released.await;
2925            let _ = stream.write_all(b"4567").await;
2926            let _ = stream.shutdown().await;
2927        });
2928        (
2929            Url::parse(&format!("http://{address}/probe")).unwrap(),
2930            first_received,
2931            release,
2932        )
2933    }
2934
2935    fn command(url: &Url) -> DecisionLoopCommand {
2936        DecisionLoopCommand::ExecuteAction {
2937            case: VerificationCase::new(
2938                "case:http:1",
2939                EntityId::new(format!("endpoint:{url}")).unwrap(),
2940                "http.probe",
2941                "hypothesis:http",
2942            )
2943            .unwrap(),
2944            executor: Some(HTTP_EVIDENCE_EXECUTOR_ID.to_owned()),
2945            origin: DecisionActionOrigin::Planned,
2946            delay_ms: None,
2947        }
2948    }
2949
2950    /// Serves a canned response for `connections` requests and captures each
2951    /// raw request head so a test can assert the exact bytes that were sent.
2952    async fn serve_capturing(
2953        response: &'static [u8],
2954        connections: usize,
2955    ) -> (Url, Arc<std::sync::Mutex<Vec<String>>>) {
2956        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2957        let address = listener.local_addr().unwrap();
2958        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
2959        let sink = captured.clone();
2960        tokio::spawn(async move {
2961            for _ in 0..connections {
2962                let (mut stream, _) = listener.accept().await.unwrap();
2963                let mut buffer = [0_u8; 2048];
2964                let read = stream.read(&mut buffer).await.unwrap();
2965                sink.lock()
2966                    .unwrap()
2967                    .push(String::from_utf8_lossy(&buffer[..read]).into_owned());
2968                stream.write_all(response).await.unwrap();
2969                stream.shutdown().await.unwrap();
2970            }
2971        });
2972        (
2973            Url::parse(&format!("http://{address}/probe")).unwrap(),
2974            captured,
2975        )
2976    }
2977
2978    #[test]
2979    fn query_payload_binding_materializes_only_one_active_parameter() {
2980        let reference = PayloadStrategyRef::new(
2981            crate::payload_strategies::EXTERNAL_URL_QUERY_PAIR_ID,
2982            crate::payload_strategies::EXTERNAL_URL_QUERY_PAIR_REVISION,
2983        )
2984        .unwrap();
2985        let strategies = crate::standard_payload_strategies().unwrap();
2986        let limits = PayloadStrategyLimits::default();
2987        let candidate = "https://case-07.review.invalid/landing?source=venom";
2988        let seed = PayloadSeed::new(candidate.as_bytes().to_vec(), limits).unwrap();
2989        let binding =
2990            HttpQueryPayloadBinding::new(strategies, reference.clone(), seed, limits, "next")
2991                .unwrap();
2992        assert_eq!(binding.reference(), &reference);
2993        assert_eq!(binding.parameter(), "next");
2994
2995        let target = Url::parse("https://authorized.example.test/review").unwrap();
2996        let control = binding
2997            .apply_to_probe(
2998                DecisionExecutionStage::Passive,
2999                HttpProbe::new(target.clone(), HttpProbeMethod::Get).unwrap(),
3000            )
3001            .unwrap();
3002        assert_eq!(control.url(), &target);
3003        assert!(control.url().query().is_none());
3004
3005        let active = binding
3006            .apply_to_probe(
3007                DecisionExecutionStage::Active,
3008                HttpProbe::new(target.clone(), HttpProbeMethod::Get).unwrap(),
3009            )
3010            .unwrap();
3011        assert_eq!(active.url().origin(), target.origin());
3012        assert_eq!(active.url().path(), target.path());
3013        assert_eq!(active.method(), HttpProbeMethod::Get);
3014        assert!(active.headers().is_empty());
3015        assert_eq!(
3016            active.url().query_pairs().collect::<Vec<_>>(),
3017            [("next".into(), candidate.into())]
3018        );
3019    }
3020
3021    #[test]
3022    fn query_payload_binding_rejects_ambiguous_or_invalid_base_state() {
3023        let reference = PayloadStrategyRef::new(
3024            crate::payload_strategies::EXTERNAL_URL_QUERY_PAIR_ID,
3025            crate::payload_strategies::EXTERNAL_URL_QUERY_PAIR_REVISION,
3026        )
3027        .unwrap();
3028        let limits = PayloadStrategyLimits::default();
3029        let seed = PayloadSeed::new(b"https://case.review.invalid/".to_vec(), limits).unwrap();
3030        for invalid in ["", "has space", "unsafe&name", "x=y"] {
3031            assert!(matches!(
3032                HttpQueryPayloadBinding::new(
3033                    crate::standard_payload_strategies().unwrap(),
3034                    reference.clone(),
3035                    seed.clone(),
3036                    limits,
3037                    invalid,
3038                ),
3039                Err(HttpEvidenceError::InvalidQueryPayloadParameter)
3040            ));
3041        }
3042        let oversized = "x".repeat(MAX_HTTP_QUERY_PAYLOAD_PARAMETER_BYTES + 1);
3043        assert!(matches!(
3044            HttpQueryPayloadBinding::new(
3045                crate::standard_payload_strategies().unwrap(),
3046                reference.clone(),
3047                seed.clone(),
3048                limits,
3049                oversized,
3050            ),
3051            Err(HttpEvidenceError::InvalidQueryPayloadParameter)
3052        ));
3053
3054        let binding = HttpQueryPayloadBinding::new(
3055            crate::standard_payload_strategies().unwrap(),
3056            reference,
3057            seed,
3058            limits,
3059            "next",
3060        )
3061        .unwrap();
3062        let existing = HttpProbe::new(
3063            Url::parse("https://authorized.example.test/review?kept=value").unwrap(),
3064            HttpProbeMethod::Get,
3065        )
3066        .unwrap();
3067        assert!(matches!(
3068            binding.apply_to_probe(DecisionExecutionStage::Passive, existing),
3069            Err(HttpEvidenceError::QueryPayloadRequiresQueryFreeProbe)
3070        ));
3071    }
3072
3073    #[tokio::test]
3074    async fn strategy_binding_derives_and_dispatches_control_then_candidate_headers() {
3075        let (url, captured) = serve_capturing(
3076            b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
3077            2,
3078        )
3079        .await;
3080
3081        let reference = PayloadStrategyRef::new(
3082            crate::HTTP_HEADER_CONTROL_PAIR_ID,
3083            crate::HTTP_HEADER_CONTROL_PAIR_REVISION,
3084        )
3085        .unwrap();
3086        let strategies = crate::standard_payload_strategies().unwrap();
3087        let limits = PayloadStrategyLimits::default();
3088        let seed = PayloadSeed::new(b"application/json".to_vec(), limits).unwrap();
3089        let binding = HttpHeaderPayloadBinding::new(
3090            strategies,
3091            reference.clone(),
3092            seed,
3093            limits,
3094            crate::HTTP_HEADER_CONTROL_PAIR_HEADER_NAME,
3095        )
3096        .unwrap();
3097
3098        let probe_url = url.clone();
3099        let provider: Arc<dyn HttpProbeProvider> =
3100            Arc::new(move |_request: &DecisionExecutionRequest| {
3101                HttpProbe::new(probe_url.clone(), HttpProbeMethod::Get)
3102            });
3103        let policy = HttpEvidencePolicy::new([url.clone()], Duration::from_secs(2), 1024).unwrap();
3104        let executor = HttpEvidenceExecutor::new(policy, provider)
3105            .unwrap()
3106            .with_payload_binding(binding);
3107        assert!(executor.supports_payload_strategy(&reference));
3108        assert!(!executor
3109            .supports_payload_strategy(&PayloadStrategyRef::new("other.strategy", 1).unwrap()));
3110        assert_eq!(executor.payload_strategy_reference(), Some(&reference));
3111
3112        let mut registry = DecisionExecutorRegistry::new();
3113        registry.register(Arc::new(executor)).unwrap();
3114        registry
3115            .route_action(
3116                DecisionExecutionStage::Active,
3117                "http.probe",
3118                HTTP_EVIDENCE_EXECUTOR_ID,
3119            )
3120            .unwrap();
3121        let adapter = DecisionRunnerAdapter::new(registry);
3122        let knowledge = KnowledgeBase::new();
3123
3124        let case = VerificationCase::new(
3125            "case:strategy:1",
3126            EntityId::new(format!("endpoint:{url}")).unwrap(),
3127            "http.probe",
3128            "hypothesis:http",
3129        )
3130        .unwrap()
3131        .with_payload_strategy(Some(reference.clone()));
3132
3133        // Passive turn derives the seed-independent Control artifact.
3134        let passive = DecisionLoopCommand::ExecuteAction {
3135            case: case.clone(),
3136            executor: Some(HTTP_EVIDENCE_EXECUTOR_ID.to_owned()),
3137            origin: DecisionActionOrigin::Planned,
3138            delay_ms: None,
3139        };
3140        adapter.execute_command(&passive, &knowledge).await.unwrap();
3141
3142        // Active verification turn derives the Candidate artifact for the pair.
3143        let active = DecisionLoopCommand::CollectActiveEvidence { case };
3144        adapter.execute_command(&active, &knowledge).await.unwrap();
3145
3146        let requests = captured.lock().unwrap().clone();
3147        assert_eq!(
3148            requests.len(),
3149            2,
3150            "expected one control and one candidate dispatch"
3151        );
3152        let control = requests[0].to_ascii_lowercase();
3153        let candidate = requests[1].to_ascii_lowercase();
3154        assert!(
3155            control.contains("accept: */*\r\n"),
3156            "control leg must send the baseline header, got: {}",
3157            requests[0]
3158        );
3159        assert!(
3160            candidate.contains("accept: */*, application/json\r\n"),
3161            "candidate leg must send the seed-derived variation, got: {}",
3162            requests[1]
3163        );
3164    }
3165
3166    #[tokio::test]
3167    async fn metered_strategy_dispatch_charges_control_and_candidate_requests() {
3168        let (url, captured) = serve_capturing(
3169            b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
3170            2,
3171        )
3172        .await;
3173
3174        let reference = PayloadStrategyRef::new(
3175            crate::HTTP_HEADER_CONTROL_PAIR_ID,
3176            crate::HTTP_HEADER_CONTROL_PAIR_REVISION,
3177        )
3178        .unwrap();
3179        let limits = PayloadStrategyLimits::default();
3180        let binding = HttpHeaderPayloadBinding::new(
3181            crate::standard_payload_strategies().unwrap(),
3182            reference.clone(),
3183            PayloadSeed::new(b"application/json".to_vec(), limits).unwrap(),
3184            limits,
3185            crate::HTTP_HEADER_CONTROL_PAIR_HEADER_NAME,
3186        )
3187        .unwrap();
3188
3189        let probe_url = url.clone();
3190        let provider: Arc<dyn HttpProbeProvider> =
3191            Arc::new(move |_request: &DecisionExecutionRequest| {
3192                HttpProbe::new(probe_url.clone(), HttpProbeMethod::Get)
3193            });
3194        let policy = HttpEvidencePolicy::new([url.clone()], Duration::from_secs(2), 1024).unwrap();
3195        let accounting = RequestAccountingBroker::new(RuntimeBudget::default());
3196        let executor =
3197            HttpEvidenceExecutor::new_with_accounting(policy, provider, accounting.clone())
3198                .unwrap()
3199                .with_payload_binding(binding);
3200
3201        let mut registry = DecisionExecutorRegistry::new();
3202        registry.register(Arc::new(executor)).unwrap();
3203        registry
3204            .route_action(
3205                DecisionExecutionStage::Active,
3206                "http.probe",
3207                HTTP_EVIDENCE_EXECUTOR_ID,
3208            )
3209            .unwrap();
3210        let adapter = DecisionRunnerAdapter::new(registry);
3211        let knowledge = KnowledgeBase::new();
3212
3213        let case = VerificationCase::new(
3214            "case:metered:1",
3215            EntityId::new(format!("endpoint:{url}")).unwrap(),
3216            "http.probe",
3217            "hypothesis:http",
3218        )
3219        .unwrap()
3220        .with_payload_strategy(Some(reference));
3221
3222        adapter
3223            .execute_command(
3224                &DecisionLoopCommand::ExecuteAction {
3225                    case: case.clone(),
3226                    executor: Some(HTTP_EVIDENCE_EXECUTOR_ID.to_owned()),
3227                    origin: DecisionActionOrigin::Planned,
3228                    delay_ms: None,
3229                },
3230                &knowledge,
3231            )
3232            .await
3233            .unwrap();
3234        adapter
3235            .execute_command(
3236                &DecisionLoopCommand::CollectActiveEvidence { case },
3237                &knowledge,
3238            )
3239            .await
3240            .unwrap();
3241
3242        // Both the derived control and candidate dispatches are charged through
3243        // the host-owned accounting broker.
3244        assert_eq!(accounting.snapshot().total_requests(), 2);
3245        assert_eq!(captured.lock().unwrap().len(), 2);
3246        assert!(!accounting.dispatch_audit().is_empty());
3247    }
3248
3249    #[tokio::test]
3250    async fn authorization_context_pair_omits_control_header_and_sends_candidate_credential() {
3251        let (url, captured) = serve_capturing(
3252            b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
3253            2,
3254        )
3255        .await;
3256
3257        let reference = PayloadStrategyRef::new(
3258            crate::API_AUTHORIZATION_CONTEXT_PAIR_ID,
3259            crate::API_AUTHORIZATION_CONTEXT_PAIR_REVISION,
3260        )
3261        .unwrap();
3262        let limits = PayloadStrategyLimits::default();
3263        let binding = HttpHeaderPayloadBinding::new(
3264            crate::standard_payload_strategies().unwrap(),
3265            reference.clone(),
3266            PayloadSeed::new(b"Bearer test-token".to_vec(), limits).unwrap(),
3267            limits,
3268            crate::API_AUTHORIZATION_CONTEXT_PAIR_HEADER_NAME,
3269        )
3270        .unwrap();
3271
3272        let probe_url = url.clone();
3273        let provider: Arc<dyn HttpProbeProvider> =
3274            Arc::new(move |_request: &DecisionExecutionRequest| {
3275                HttpProbe::new(probe_url.clone(), HttpProbeMethod::Get)
3276            });
3277        let policy = HttpEvidencePolicy::new([url.clone()], Duration::from_secs(2), 1024).unwrap();
3278        let executor = HttpEvidenceExecutor::new(policy, provider)
3279            .unwrap()
3280            .with_payload_binding(binding);
3281
3282        let mut registry = DecisionExecutorRegistry::new();
3283        registry.register(Arc::new(executor)).unwrap();
3284        registry
3285            .route_action(
3286                DecisionExecutionStage::Active,
3287                "http.probe",
3288                HTTP_EVIDENCE_EXECUTOR_ID,
3289            )
3290            .unwrap();
3291        let adapter = DecisionRunnerAdapter::new(registry);
3292        let knowledge = KnowledgeBase::new();
3293
3294        let case = VerificationCase::new(
3295            "case:authz:1",
3296            EntityId::new(format!("endpoint:{url}")).unwrap(),
3297            "http.probe",
3298            "hypothesis:http",
3299        )
3300        .unwrap()
3301        .with_payload_strategy(Some(reference));
3302
3303        // Passive turn derives the empty Control artifact: anonymous context.
3304        adapter
3305            .execute_command(
3306                &DecisionLoopCommand::ExecuteAction {
3307                    case: case.clone(),
3308                    executor: Some(HTTP_EVIDENCE_EXECUTOR_ID.to_owned()),
3309                    origin: DecisionActionOrigin::Planned,
3310                    delay_ms: None,
3311                },
3312                &knowledge,
3313            )
3314            .await
3315            .unwrap();
3316
3317        // Active turn derives the Candidate credential: authorized context.
3318        adapter
3319            .execute_command(
3320                &DecisionLoopCommand::CollectActiveEvidence { case },
3321                &knowledge,
3322            )
3323            .await
3324            .unwrap();
3325
3326        let requests = captured.lock().unwrap().clone();
3327        assert_eq!(requests.len(), 2);
3328        let control = requests[0].to_ascii_lowercase();
3329        let candidate = requests[1].to_ascii_lowercase();
3330        assert!(
3331            !control.contains("authorization:"),
3332            "control leg must be anonymous (no authorization header), got: {}",
3333            requests[0]
3334        );
3335        assert!(
3336            candidate.contains("authorization: bearer test-token\r\n"),
3337            "candidate leg must send the authorized credential, got: {}",
3338            requests[1]
3339        );
3340    }
3341
3342    fn adapter(
3343        url: &Url,
3344        capture: HttpBodyCapture,
3345        max_body_bytes: usize,
3346    ) -> DecisionRunnerAdapter {
3347        let probe_url = url.clone();
3348        let provider: Arc<dyn HttpProbeProvider> =
3349            Arc::new(move |_request: &DecisionExecutionRequest| {
3350                HttpProbe::new(probe_url.clone(), HttpProbeMethod::Get)
3351            });
3352        let policy = HttpEvidencePolicy::new([url.clone()], Duration::from_secs(2), max_body_bytes)
3353            .unwrap()
3354            .with_body_capture(capture)
3355            .unwrap();
3356        let executor = HttpEvidenceExecutor::new(policy, provider).unwrap();
3357        let mut registry = DecisionExecutorRegistry::new();
3358        registry.register(Arc::new(executor)).unwrap();
3359        DecisionRunnerAdapter::new(registry)
3360    }
3361
3362    fn metered_adapter(
3363        url: &Url,
3364        policy: HttpEvidencePolicy,
3365        budget: RuntimeBudget,
3366    ) -> (DecisionRunnerAdapter, RequestAccountingBroker) {
3367        let probe_url = url.clone();
3368        let provider: Arc<dyn HttpProbeProvider> =
3369            Arc::new(move |_request: &DecisionExecutionRequest| {
3370                HttpProbe::new(probe_url.clone(), HttpProbeMethod::Get)
3371            });
3372        let accounting = RequestAccountingBroker::new(budget);
3373        let executor =
3374            HttpEvidenceExecutor::new_with_accounting(policy, provider, accounting.clone())
3375                .unwrap();
3376        let mut registry = DecisionExecutorRegistry::new();
3377        registry.register(Arc::new(executor)).unwrap();
3378        (DecisionRunnerAdapter::new(registry), accounting)
3379    }
3380
3381    fn buffered_adapter(
3382        url: &Url,
3383        budget: RuntimeBudget,
3384        body: impl Into<Vec<u8>>,
3385    ) -> (DecisionRunnerAdapter, RequestAccountingBroker) {
3386        let policy = HttpEvidencePolicy::for_origin(url.clone()).unwrap();
3387        let accounting = RequestAccountingBroker::new(budget);
3388        let requests = HttpRequestBroker::new_metered(policy, accounting.clone()).unwrap();
3389        let mut registry = DecisionExecutorRegistry::new();
3390        registry
3391            .register(Arc::new(BufferedRequestExecutor {
3392                requests,
3393                target: url.clone(),
3394                body: body.into(),
3395            }))
3396            .unwrap();
3397        (DecisionRunnerAdapter::new(registry), accounting)
3398    }
3399
3400    fn value<P>(evidence: &[Evidence], predicate: P) -> Option<&EvidenceValue>
3401    where
3402        P: Into<KnowledgePredicate>,
3403    {
3404        let predicate = predicate.into();
3405        evidence
3406            .iter()
3407            .find(|item| item.predicate() == &predicate)
3408            .map(Evidence::value)
3409    }
3410
3411    fn record<P>(evidence: &[Evidence], predicate: P) -> Option<&Evidence>
3412    where
3413        P: Into<KnowledgePredicate>,
3414    {
3415        let predicate = predicate.into();
3416        evidence.iter().find(|item| item.predicate() == &predicate)
3417    }
3418
3419    /// Serves one `text/html` response with an auto-computed `Content-Length`, so
3420    /// tests can vary the HTML body without hand-counting bytes.
3421    async fn serve_html_once(body: impl Into<String>) -> Url {
3422        serve_body_once("text/html", body).await
3423    }
3424
3425    async fn serve_body_once(content_type: &str, body: impl Into<String>) -> Url {
3426        let body = body.into();
3427        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3428        let address = listener.local_addr().unwrap();
3429        let response = format!(
3430            "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3431            body.len(),
3432            body
3433        );
3434        tokio::spawn(async move {
3435            let (mut stream, _) = listener.accept().await.unwrap();
3436            let mut request = [0_u8; 2048];
3437            let _ = stream.read(&mut request).await.unwrap();
3438            stream.write_all(response.as_bytes()).await.unwrap();
3439            stream.shutdown().await.unwrap();
3440        });
3441        Url::parse(&format!("http://{address}/probe")).unwrap()
3442    }
3443
3444    /// A GET executor with form-control capture enabled, under the given body
3445    /// policy — the exact wiring the php input-discovery route uses.
3446    fn form_capturing_adapter(url: &Url, capture: HttpBodyCapture) -> DecisionRunnerAdapter {
3447        form_capturing_adapter_with_limit(url, capture, 65_536)
3448    }
3449
3450    fn form_capturing_adapter_with_limit(
3451        url: &Url,
3452        capture: HttpBodyCapture,
3453        max_body_bytes: usize,
3454    ) -> DecisionRunnerAdapter {
3455        let probe_url = url.clone();
3456        let provider: Arc<dyn HttpProbeProvider> =
3457            Arc::new(move |_request: &DecisionExecutionRequest| {
3458                HttpProbe::new(probe_url.clone(), HttpProbeMethod::Get)
3459            });
3460        let policy = HttpEvidencePolicy::new([url.clone()], Duration::from_secs(2), max_body_bytes)
3461            .unwrap()
3462            .with_body_capture(capture)
3463            .unwrap();
3464        let executor = HttpEvidenceExecutor::new(policy, provider)
3465            .unwrap()
3466            .with_form_control_capture();
3467        let mut registry = DecisionExecutorRegistry::new();
3468        registry.register(Arc::new(executor)).unwrap();
3469        DecisionRunnerAdapter::new(registry)
3470    }
3471
3472    #[tokio::test]
3473    async fn form_control_capture_is_suppressed_under_metadata_only_policy() {
3474        let url = serve_html_once("<form><input name=\"username\"></form>").await;
3475        let adapter = form_capturing_adapter(&url, HttpBodyCapture::MetadataOnly);
3476        let knowledge = KnowledgeBase::new();
3477
3478        let receipt = adapter
3479            .execute_command(&command(&url), &knowledge)
3480            .await
3481            .unwrap();
3482        let evidence = receipt.after_execution().evidence();
3483
3484        // MetadataOnly authorizes no bounded sample, so no body content — and
3485        // therefore no derived form-control names — may enter the knowledge base.
3486        assert!(value(evidence, HttpEvidencePredicate::RESPONSE_BODY_SAMPLE).is_none());
3487        assert!(value(evidence, HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES).is_none());
3488    }
3489
3490    #[tokio::test]
3491    async fn form_control_capture_requires_explicit_executor_opt_in() {
3492        let body = "<input name=\"not-authorized\">";
3493        let url = serve_html_once(body).await;
3494        let adapter = adapter(&url, HttpBodyCapture::TextSample { max_chars: 1024 }, 1024);
3495        let knowledge = KnowledgeBase::new();
3496
3497        let receipt = adapter
3498            .execute_command(&command(&url), &knowledge)
3499            .await
3500            .unwrap();
3501        let evidence = receipt.after_execution().evidence();
3502
3503        assert_eq!(
3504            value(evidence, HttpEvidencePredicate::RESPONSE_BODY_SAMPLE),
3505            Some(&EvidenceValue::Text(body.to_owned()))
3506        );
3507        assert!(value(evidence, HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES).is_none());
3508    }
3509
3510    #[tokio::test]
3511    async fn form_control_capture_requires_exact_html_media_type() {
3512        let body = "<input name=\"not-html\">";
3513        let url = serve_body_once("text/plain", body).await;
3514        let adapter = form_capturing_adapter(&url, HttpBodyCapture::TextSample { max_chars: 8192 });
3515        let knowledge = KnowledgeBase::new();
3516
3517        let receipt = adapter
3518            .execute_command(&command(&url), &knowledge)
3519            .await
3520            .unwrap();
3521        let evidence = receipt.after_execution().evidence();
3522
3523        assert_eq!(
3524            value(evidence, HttpEvidencePredicate::RESPONSE_BODY_SAMPLE),
3525            Some(&EvidenceValue::Text(body.to_owned()))
3526        );
3527        assert!(value(evidence, HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES).is_none());
3528    }
3529
3530    #[tokio::test]
3531    async fn form_control_names_predicate_carries_names_only_not_values() {
3532        let url = serve_html_once(
3533            "<input type=\"hidden\" name=\"_token\" value=\"SUPER_SECRET_CSRF\">\
3534             <input type=\"password\" name=\"password\" value=\"hunter2\">",
3535        )
3536        .await;
3537        let adapter = form_capturing_adapter(&url, HttpBodyCapture::TextSample { max_chars: 8192 });
3538        let knowledge = KnowledgeBase::new();
3539
3540        let receipt = adapter
3541            .execute_command(&command(&url), &knowledge)
3542            .await
3543            .unwrap();
3544        let evidence = receipt.after_execution().evidence();
3545
3546        // The derived form-control-names predicate carries only control names,
3547        // deduplicated and sorted; control values are never copied into it.
3548        let names = value(evidence, HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES);
3549        assert_eq!(
3550            names,
3551            Some(&EvidenceValue::TextList(vec![
3552                "_token".to_owned(),
3553                "password".to_owned()
3554            ]))
3555        );
3556        if let Some(EvidenceValue::TextList(list)) = names {
3557            assert!(list.iter().all(|name| name != "SUPER_SECRET_CSRF"));
3558            assert!(list.iter().all(|name| name != "hunter2"));
3559        }
3560        // Note: this asserts the boundary of the DERIVED predicate only. The
3561        // separate host-authorized RESPONSE_BODY_SAMPLE intentionally contains the
3562        // original bounded HTML and may include these value= contents; that is not
3563        // this feature's concern and is deliberately not asserted here.
3564    }
3565
3566    #[tokio::test]
3567    async fn form_control_names_predicate_preserves_exact_attribute_value() {
3568        let url = serve_html_once("<form><input name=\" _token \"></form>").await;
3569        let adapter = form_capturing_adapter(&url, HttpBodyCapture::TextSample { max_chars: 8192 });
3570        let knowledge = KnowledgeBase::new();
3571
3572        let receipt = adapter
3573            .execute_command(&command(&url), &knowledge)
3574            .await
3575            .unwrap();
3576
3577        assert_eq!(
3578            value(
3579                receipt.after_execution().evidence(),
3580                HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES
3581            ),
3582            Some(&EvidenceValue::TextList(vec![" _token ".to_owned()]))
3583        );
3584    }
3585
3586    #[tokio::test]
3587    async fn form_control_names_are_derived_from_the_exact_body_sample() {
3588        let url = serve_html_once("<form><input name=\"_token\"></form>").await;
3589        let adapter = form_capturing_adapter(&url, HttpBodyCapture::TextSample { max_chars: 8192 });
3590        let knowledge = KnowledgeBase::new();
3591
3592        let receipt = adapter
3593            .execute_command(&command(&url), &knowledge)
3594            .await
3595            .unwrap();
3596        let evidence = receipt.after_execution().evidence();
3597
3598        let body_sample = record(evidence, HttpEvidencePredicate::RESPONSE_BODY_SAMPLE).unwrap();
3599        let form_controls =
3600            record(evidence, HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES).unwrap();
3601
3602        // The body sample is a direct observation; the form-control record is
3603        // derived from exactly that record — not merely case-correlated with it.
3604        assert!(body_sample.origin().is_direct());
3605        let derivation = form_controls
3606            .origin()
3607            .derivation()
3608            .expect("form-control evidence must carry derivation lineage");
3609        assert_eq!(derivation.parents(), std::slice::from_ref(body_sample.id()));
3610        assert_eq!(derivation.algorithm().name(), "http.form-control-names");
3611        assert_eq!(derivation.algorithm().version(), 1);
3612        // Same subject and same case as the parent.
3613        assert_eq!(form_controls.subject(), body_sample.subject());
3614        assert_eq!(
3615            form_controls.source().correlation_id(),
3616            body_sample.source().correlation_id()
3617        );
3618
3619        // The committed knowledge base exposes the reverse edge.
3620        assert!(knowledge
3621            .derivation_children(body_sample.id())
3622            .contains(form_controls.id()));
3623    }
3624
3625    #[tokio::test]
3626    async fn form_control_capture_rejects_foreign_namespace_lookalikes() {
3627        let body = "<svg><input name=\"_token\"></input></svg>\
3628                    <math><select name=\"_method\"></select></math>";
3629        let url = serve_html_once(body).await;
3630        let adapter = form_capturing_adapter(&url, HttpBodyCapture::TextSample { max_chars: 8192 });
3631        let knowledge = KnowledgeBase::new();
3632
3633        let receipt = adapter
3634            .execute_command(&command(&url), &knowledge)
3635            .await
3636            .unwrap();
3637        let evidence = receipt.after_execution().evidence();
3638
3639        assert_eq!(
3640            value(evidence, HttpEvidencePredicate::RESPONSE_BODY_SAMPLE),
3641            Some(&EvidenceValue::Text(body.to_owned())),
3642            "the sample capture path must actually run"
3643        );
3644        assert!(
3645            value(evidence, HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES).is_none(),
3646            "foreign elements must not produce typed HTML form-control evidence"
3647        );
3648    }
3649
3650    #[tokio::test]
3651    async fn oversized_form_control_sample_remains_body_evidence_but_is_not_parsed() {
3652        use super::form_controls::MAX_FORM_CONTROL_PARSE_BYTES;
3653
3654        let mut body = "<input name=\"must-not-be-partially-observed\">".to_owned();
3655        body.push_str(&"x".repeat(MAX_FORM_CONTROL_PARSE_BYTES + 1 - body.len()));
3656        let url = serve_html_once(body.clone()).await;
3657        let adapter = form_capturing_adapter_with_limit(
3658            &url,
3659            HttpBodyCapture::TextSample {
3660                max_chars: body.len(),
3661            },
3662            body.len(),
3663        );
3664        let knowledge = KnowledgeBase::new();
3665
3666        let receipt = adapter
3667            .execute_command(&command(&url), &knowledge)
3668            .await
3669            .unwrap();
3670        let evidence = receipt.after_execution().evidence();
3671
3672        assert_eq!(
3673            value(evidence, HttpEvidencePredicate::RESPONSE_BODY_SAMPLE),
3674            Some(&EvidenceValue::Text(body))
3675        );
3676        assert!(
3677            value(evidence, HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES).is_none(),
3678            "an over-limit sample must not be partially parsed"
3679        );
3680    }
3681
3682    #[tokio::test]
3683    async fn form_control_past_the_sample_boundary_is_not_observed() {
3684        // The early control is within the 64-char sample; the late one is past it
3685        // and must not be discovered — the bounded sample never implies a complete
3686        // set.
3687        let padding = "x".repeat(80);
3688        let url = serve_html_once(format!(
3689            "<input name=\"early\">{padding}<input name=\"late\">"
3690        ))
3691        .await;
3692        let adapter = form_capturing_adapter(&url, HttpBodyCapture::TextSample { max_chars: 64 });
3693        let knowledge = KnowledgeBase::new();
3694
3695        let receipt = adapter
3696            .execute_command(&command(&url), &knowledge)
3697            .await
3698            .unwrap();
3699        let evidence = receipt.after_execution().evidence();
3700
3701        assert_eq!(
3702            value(evidence, HttpEvidencePredicate::RESPONSE_FORM_CONTROL_NAMES),
3703            Some(&EvidenceValue::TextList(vec!["early".to_owned()]))
3704        );
3705    }
3706
3707    #[tokio::test]
3708    async fn executor_emits_typed_status_headers_body_and_timing() {
3709        let url = serve_once(
3710            b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nServer: test-server\r\nSet-Cookie: secret=value\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
3711        )
3712        .await;
3713        let adapter = adapter(&url, HttpBodyCapture::TextSample { max_chars: 64 }, 1024);
3714        let knowledge = KnowledgeBase::new();
3715
3716        let receipt = adapter
3717            .execute_command(&command(&url), &knowledge)
3718            .await
3719            .unwrap();
3720        let evidence = receipt.after_execution().evidence();
3721
3722        assert_eq!(
3723            value(evidence, HttpEvidencePredicate::RESPONSE_STATUS),
3724            Some(&EvidenceValue::Unsigned(200))
3725        );
3726        assert_eq!(
3727            value(evidence, HttpEvidencePredicate::HEADER_CONTENT_TYPE),
3728            Some(&EvidenceValue::Text("application/json".to_owned()))
3729        );
3730        assert_eq!(
3731            value(evidence, HttpEvidencePredicate::REQUEST_PATH_SEGMENT),
3732            Some(&EvidenceValue::Text("probe".to_owned()))
3733        );
3734        assert_eq!(
3735            value(evidence, HttpEvidencePredicate::RESPONSE_MEDIA_TYPE),
3736            Some(&EvidenceValue::Text("application/json".to_owned()))
3737        );
3738        assert_eq!(
3739            value(
3740                evidence,
3741                HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE,
3742            ),
3743            Some(&EvidenceValue::Boolean(true))
3744        );
3745        assert_eq!(
3746            value(evidence, HttpEvidencePredicate::RESPONSE_BODY_SAMPLE),
3747            Some(&EvidenceValue::Text("{\"ok\":true}".to_owned()))
3748        );
3749        assert_eq!(
3750            value(
3751                evidence,
3752                HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
3753            ),
3754            Some(&EvidenceValue::Unsigned(11))
3755        );
3756        assert!(value(evidence, HttpEvidencePredicate::TIMING_TTFB_MS).is_some());
3757        assert!(value(evidence, HttpEvidencePredicate::TIMING_TOTAL_MS).is_some());
3758        assert!(value(
3759            evidence,
3760            HttpEvidencePredicate::response_header("set-cookie").unwrap()
3761        )
3762        .is_none());
3763        assert_eq!(
3764            value(evidence, HttpEvidencePredicate::COOKIE_NAME),
3765            Some(&EvidenceValue::Text("secret".to_owned()))
3766        );
3767        assert!(evidence.iter().all(|item| {
3768            item.source().component() == HTTP_EVIDENCE_EXECUTOR_ID
3769                && item.source().correlation_id() == Some("case:http:1")
3770        }));
3771    }
3772
3773    #[tokio::test]
3774    async fn complete_response_observer_error_commits_none_of_the_atomic_batch() {
3775        let url = serve_once(
3776            b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nSet-Cookie: DEBUG_COOKIE_NAME_SENTINEL=value\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
3777        )
3778        .await;
3779        let probe_url = url.clone();
3780        let provider: Arc<dyn HttpProbeProvider> =
3781            Arc::new(move |_request: &DecisionExecutionRequest| {
3782                HttpProbe::new(probe_url.clone(), HttpProbeMethod::Get)
3783            });
3784        let policy = HttpEvidencePolicy::new([url.clone()], Duration::from_secs(2), 1024)
3785            .unwrap()
3786            .with_body_capture(HttpBodyCapture::MetadataOnly)
3787            .unwrap();
3788        let executor = HttpEvidenceExecutor::new(policy, provider)
3789            .unwrap()
3790            .with_complete_response_observer(Arc::new(RejectingCompleteResponseObserver));
3791        let mut registry = DecisionExecutorRegistry::new();
3792        registry.register(Arc::new(executor)).unwrap();
3793        let adapter = DecisionRunnerAdapter::new(registry);
3794        let knowledge = KnowledgeBase::new();
3795
3796        let error = adapter
3797            .execute_command(&command(&url), &knowledge)
3798            .await
3799            .unwrap_err();
3800
3801        assert!(error
3802            .execution_failure()
3803            .unwrap()
3804            .diagnostic()
3805            .contains("test-observer-rejected"));
3806        assert_eq!(knowledge.stats().evidence, 0);
3807        assert!(knowledge
3808            .evidence_for_predicate(&HttpEvidencePredicate::COOKIE_NAME.into_knowledge())
3809            .is_empty());
3810        assert!(!format!("{error:?}").contains("DEBUG_COOKIE_NAME_SENTINEL"));
3811    }
3812
3813    #[tokio::test]
3814    async fn producer_output_feeds_a_method_agnostic_endpoint_entity() {
3815        // End-to-end production contract: the real HttpEvidenceExecutor output is
3816        // fed into the EntityExtractor. With the standard SubjectHttpProbeProvider
3817        // the case subject and probe URL are the same absolute request URL, so the
3818        // emitted url+method evidence merge into exactly one method-agnostic
3819        // Endpoint entity. No external network: a loopback response seam is used.
3820        let url =
3821            serve_once(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await;
3822        let adapter = adapter(&url, HttpBodyCapture::MetadataOnly, 1024);
3823        let knowledge = KnowledgeBase::new();
3824
3825        let receipt = adapter
3826            .execute_command(&command(&url), &knowledge)
3827            .await
3828            .unwrap();
3829        let evidence = receipt.after_execution().evidence();
3830
3831        // The real producer emits an uppercase GET and the probe URL, correlated
3832        // by the case id.
3833        assert_eq!(
3834            value(evidence, HttpEvidencePredicate::REQUEST_METHOD),
3835            Some(&EvidenceValue::Text("GET".to_owned()))
3836        );
3837        assert_eq!(
3838            value(evidence, HttpEvidencePredicate::REQUEST_URL),
3839            Some(&EvidenceValue::Text(url.to_string()))
3840        );
3841        let method_evidence = evidence
3842            .iter()
3843            .find(|item| item.predicate() == &HttpEvidencePredicate::REQUEST_METHOD.into())
3844            .expect("producer must emit a request method");
3845        assert_eq!(method_evidence.kind(), &EvidenceKind::Http);
3846        assert_eq!(
3847            method_evidence.source().component(),
3848            HTTP_EVIDENCE_EXECUTOR_ID
3849        );
3850        assert_eq!(
3851            method_evidence.source().correlation_id(),
3852            Some("case:http:1")
3853        );
3854        // Standard subject convention: subject == the absolute request URL.
3855        assert_eq!(
3856            method_evidence.subject().as_str(),
3857            format!("endpoint:{url}")
3858        );
3859
3860        let extraction = crate::EntityExtractor::new().extract_from_evidence(evidence);
3861        let endpoint = extraction
3862            .entities
3863            .iter()
3864            .find(|entity| entity.entity_type() == crate::SemanticEntityType::Endpoint)
3865            .expect("producer evidence must yield an endpoint entity");
3866
3867        // Identity is method-agnostic: the observed method is an attribute only.
3868        assert_eq!(endpoint.id().as_str(), format!("v1:endpoint:{url}"));
3869        assert!(!endpoint.id().as_str().contains('#'));
3870        assert_eq!(
3871            endpoint.attributes().get("method"),
3872            Some(&std::collections::BTreeSet::from(["GET".to_owned()]))
3873        );
3874        assert_eq!(
3875            endpoint.attributes().get("url"),
3876            Some(&std::collections::BTreeSet::from([url.to_string()]))
3877        );
3878    }
3879
3880    #[tokio::test]
3881    async fn typed_http_evidence_drives_standard_web_reasoning_without_cookie_secrets() {
3882        let url = serve_once(
3883            b"HTTP/1.1 200 OK\r\nX-Powered-By: PHP/8.3\r\nSet-Cookie: laravel_session=secret-one; Path=/; HttpOnly\r\nSet-Cookie: XSRF-TOKEN=secret-two; Path=/\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
3884        )
3885        .await;
3886        let adapter = adapter(&url, HttpBodyCapture::MetadataOnly, 1024);
3887        let knowledge = KnowledgeBase::new();
3888
3889        adapter
3890            .execute_command(&command(&url), &knowledge)
3891            .await
3892            .unwrap();
3893        let mut rules = RuleEngine::new();
3894        StandardWebReasoning::new()
3895            .unwrap()
3896            .install(&knowledge, &mut rules)
3897            .unwrap();
3898        rules
3899            .apply(
3900                &knowledge,
3901                &EntityId::new(format!("endpoint:{url}")).unwrap(),
3902            )
3903            .unwrap();
3904
3905        let hypotheses =
3906            knowledge.hypotheses_for_subject(&EntityId::new(format!("endpoint:{url}")).unwrap());
3907        let laravel = hypotheses
3908            .iter()
3909            .find(|item| item.value() == &EvidenceValue::Text("laravel".to_owned()))
3910            .unwrap();
3911        assert_eq!(laravel.strength(), HypothesisStrength::Strong);
3912        assert!(hypotheses
3913            .iter()
3914            .any(|item| item.value() == &EvidenceValue::Text("sanctum".to_owned())));
3915        assert!(knowledge
3916            .evidence_for_subject(laravel.subject())
3917            .iter()
3918            .all(|item| match item.value() {
3919                EvidenceValue::Text(value) => !value.contains("secret-"),
3920                _ => true,
3921            }));
3922    }
3923
3924    #[test]
3925    fn cookie_name_extraction_deduplicates_names_without_retaining_values() {
3926        let mut headers = HeaderMap::new();
3927        headers.append(
3928            "set-cookie",
3929            HeaderValue::from_static("laravel_session=secret-one; Path=/; HttpOnly"),
3930        );
3931        headers.append(
3932            "set-cookie",
3933            HeaderValue::from_static("XSRF-TOKEN=secret-two; Path=/"),
3934        );
3935        headers.append(
3936            "set-cookie",
3937            HeaderValue::from_static("laravel_session=rotated; Path=/"),
3938        );
3939        headers.append("set-cookie", HeaderValue::from_static("bad name=value"));
3940
3941        assert_eq!(
3942            response_cookie_names(&headers),
3943            BTreeSet::from(["XSRF-TOKEN".to_owned(), "laravel_session".to_owned()])
3944        );
3945    }
3946
3947    #[cfg(feature = "wordpress-review")]
3948    #[test]
3949    fn wordpress_rest_link_projection_accepts_only_closed_same_origin_candidates() {
3950        let document = Url::parse("https://example.test/").unwrap();
3951        for query in ["rest_route=/", "rest_route=%2F"] {
3952            let mut headers = HeaderMap::new();
3953            let value = format!("</index.php?{query}>; rel=\"https://api.w.org/\"");
3954            headers.insert("link", HeaderValue::from_str(&value).unwrap());
3955            assert_eq!(
3956                project_wordpress_rest_index_advertisement(&document, &headers),
3957                WordPressRestIndexAdvertisement::Candidate(
3958                    Url::parse(&format!("https://example.test/index.php?{query}")).unwrap()
3959                )
3960            );
3961        }
3962
3963        let mut headers = HeaderMap::new();
3964        headers.append(
3965            "link",
3966            HeaderValue::from_static("</wp-json/>; rel=\"https://api.w.org/\""),
3967        );
3968        headers.append(
3969            "link",
3970            HeaderValue::from_static(
3971                "<https://foreign.invalid/wp-json/>; rel=\"https://api.w.org/\"",
3972            ),
3973        );
3974        assert_eq!(
3975            project_wordpress_rest_index_advertisement(&document, &headers),
3976            WordPressRestIndexAdvertisement::InvalidOrAmbiguous
3977        );
3978    }
3979
3980    #[cfg(feature = "wordpress-review")]
3981    #[test]
3982    fn wordpress_rest_link_projection_rejects_ambiguous_or_unsafe_values() {
3983        let document = Url::parse("https://example.test/").unwrap();
3984        for value in [
3985            "<>; rel=\"https://api.w.org/\"",
3986            "</wp-json/>; rel=\"https://api.w.org/\", </index.php?rest_route=/>; rel=\"https://api.w.org/\"",
3987            "</index.php?rest_route=/&token=secret>; rel=\"https://api.w.org/\"",
3988            "</index.php?rest_route=/;token=secret>; rel=\"https://api.w.org/\"",
3989            "</index.php?rest_route=%2f>; rel=\"https://api.w.org/\"",
3990            "</index.php?%72est_route=%2F>; rel=\"https://api.w.org/\"",
3991            "</index.php?rest%5Froute=%2F>; rel=\"https://api.w.org/\"",
3992            "</index.php?rest_route=%252F>; rel=\"https://api.w.org/\"",
3993            "</index.php?rest_route=%2F&rest_route=%2F>; rel=\"https://api.w.org/\"",
3994            "</index.php?REST_ROUTE=%2F>; rel=\"https://api.w.org/\"",
3995            "</wp-json/>; rel=\"https://api.w.org/\"; title=\"bad\\value\"",
3996            "</x/%2e%2e/wp-json/>; rel=\"https://api.w.org/\"",
3997            "</x/%252e%252e/wp-json/>; rel=\"https://api.w.org/\"",
3998            "<https:///example.test/wp-json/>; rel=\"https://api.w.org/\"",
3999            "<https:/example.test/wp-json/>; rel=\"https://api.w.org/\"",
4000            "<https:example.test/wp-json/>; rel=\"https://api.w.org/\"",
4001            "<https://@example.test/wp-json/>; rel=\"https://api.w.org/\"",
4002            "<//@example.test/wp-json/>; rel=\"https://api.w.org/\"",
4003        ] {
4004            let mut headers = HeaderMap::new();
4005            headers.insert("link", HeaderValue::from_str(value).unwrap());
4006            assert_eq!(
4007                project_wordpress_rest_index_advertisement(&document, &headers),
4008                WordPressRestIndexAdvertisement::InvalidOrAmbiguous
4009            );
4010        }
4011    }
4012
4013    #[cfg(feature = "wordpress-review")]
4014    #[test]
4015    fn wordpress_reference_syntax_rejects_url_parser_repairs() {
4016        for reference in [
4017            "/blog/wp-json/",
4018            "wp-content/themes/child/style.css",
4019            "?rest_route=/",
4020            "https://example.test/blog/wp-json/",
4021            "//example.test/blog/wp-json/",
4022        ] {
4023            assert!(wordpress_reference_path_is_safe(reference), "{reference:?}");
4024        }
4025        for reference in [
4026            " https://example.test/blog/wp-json/",
4027            "https:///example.test/blog/wp-json/",
4028            "https:/example.test/blog/wp-json/",
4029            "https:example.test/blog/wp-json/",
4030            "https://@example.test/blog/wp-json/",
4031            "//@example.test/blog/wp-json/",
4032            "https://%65xample.test/blog/wp-json/",
4033            "https://EXAMPLE.test/blog/wp-json/",
4034            "https://bücher.example/blog/wp-json/",
4035            "http://2130706433/blog/wp-json/",
4036            "http://127.1/blog/wp-json/",
4037            "http://example.test:80/blog/wp-json/",
4038            "//%65xample.test/blog/wp-json/",
4039            "//EXAMPLE.test/blog/wp-json/",
4040            "//2130706433/blog/wp-json/",
4041            "javascript:alert(1)",
4042        ] {
4043            assert!(
4044                !wordpress_reference_path_is_safe(reference),
4045                "unexpectedly admitted {reference:?}"
4046            );
4047        }
4048    }
4049
4050    #[cfg(feature = "wordpress-review")]
4051    #[test]
4052    fn wordpress_rest_link_parameters_do_not_split_inside_quoted_strings() {
4053        let document = Url::parse("https://example.test/").unwrap();
4054        let mut headers = HeaderMap::new();
4055        headers.insert(
4056            "link",
4057            HeaderValue::from_static("</wp-json/>; title=\"x; rel=https://api.w.org/; y\""),
4058        );
4059        assert_eq!(
4060            project_wordpress_rest_index_advertisement(&document, &headers),
4061            WordPressRestIndexAdvertisement::Missing
4062        );
4063
4064        headers.insert(
4065            "link",
4066            HeaderValue::from_static("</wp-json/>; title=\"unterminated; rel=https://api.w.org/"),
4067        );
4068        assert_eq!(
4069            project_wordpress_rest_index_advertisement(&document, &headers),
4070            WordPressRestIndexAdvertisement::InvalidOrAmbiguous
4071        );
4072    }
4073
4074    #[test]
4075    fn media_type_normalization_is_exact_and_fail_closed() {
4076        let mut headers = HeaderMap::new();
4077        headers.insert(
4078            "content-type",
4079            HeaderValue::from_static("Application/Problem+JSON; charset=UTF-8"),
4080        );
4081        let normalized = normalized_media_type(&headers).unwrap();
4082        assert_eq!(normalized, "application/problem+json");
4083        assert!(json_compatible_media_type(&normalized));
4084
4085        headers.insert(
4086            "content-type",
4087            HeaderValue::from_static("application/jsonp"),
4088        );
4089        let normalized = normalized_media_type(&headers).unwrap();
4090        assert!(!json_compatible_media_type(&normalized));
4091
4092        headers.insert(
4093            "content-type",
4094            HeaderValue::from_static("application/graphql-response+json"),
4095        );
4096        let normalized = normalized_media_type(&headers).unwrap();
4097        assert!(json_compatible_media_type(&normalized));
4098
4099        headers.insert(
4100            "content-type",
4101            HeaderValue::from_static("application/json/extra"),
4102        );
4103        assert!(normalized_media_type(&headers).is_none());
4104
4105        let mut ambiguous = HeaderMap::new();
4106        ambiguous.append("content-type", HeaderValue::from_static("application/json"));
4107        ambiguous.append("content-type", HeaderValue::from_static("text/plain"));
4108        assert!(normalized_media_type(&ambiguous).is_none());
4109    }
4110
4111    #[tokio::test]
4112    async fn query_text_does_not_become_a_path_segment_signal() {
4113        let mut url =
4114            serve_once(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await;
4115        url.set_query(Some("next=/graphql"));
4116        let adapter = adapter(&url, HttpBodyCapture::MetadataOnly, 1024);
4117        let knowledge = KnowledgeBase::new();
4118
4119        let receipt = adapter
4120            .execute_command(&command(&url), &knowledge)
4121            .await
4122            .unwrap();
4123        let path_predicate = HttpEvidencePredicate::REQUEST_PATH_SEGMENT.into_knowledge();
4124        let segments = receipt
4125            .after_execution()
4126            .evidence()
4127            .iter()
4128            .filter(|item| item.predicate() == &path_predicate)
4129            .map(Evidence::value)
4130            .collect::<Vec<_>>();
4131
4132        assert_eq!(segments, vec![&EvidenceValue::Text("probe".to_owned())]);
4133    }
4134
4135    #[tokio::test]
4136    async fn response_body_is_bounded_and_hashed_as_observed() {
4137        let url = serve_once(
4138            b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 10\r\nConnection: close\r\n\r\n0123456789",
4139        )
4140        .await;
4141        let adapter = adapter(&url, HttpBodyCapture::MetadataOnly, 4);
4142        let knowledge = KnowledgeBase::new();
4143
4144        let receipt = adapter
4145            .execute_command(&command(&url), &knowledge)
4146            .await
4147            .unwrap();
4148        let evidence = receipt.after_execution().evidence();
4149
4150        assert_eq!(
4151            value(
4152                evidence,
4153                HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
4154            ),
4155            Some(&EvidenceValue::Unsigned(4))
4156        );
4157        assert_eq!(
4158            value(evidence, HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED),
4159            Some(&EvidenceValue::Boolean(true))
4160        );
4161        assert_eq!(
4162            value(evidence, HttpEvidencePredicate::RESPONSE_BODY_SHA256),
4163            Some(&EvidenceValue::Text(format!(
4164                "{:x}",
4165                Sha256::digest(b"0123")
4166            )))
4167        );
4168        assert!(value(evidence, HttpEvidencePredicate::RESPONSE_BODY_SAMPLE).is_none());
4169    }
4170
4171    #[tokio::test]
4172    async fn rate_limit_response_emits_status_and_typed_policy_evidence() {
4173        let url = serve_once(
4174            b"HTTP/1.1 429 Too Many Requests\r\nRetry-After: 7\r\nX-RateLimit-Limit: 100\r\nRateLimit-Remaining: 3\r\nX-RateLimit-Remaining: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
4175        )
4176        .await;
4177        let adapter = adapter(&url, HttpBodyCapture::MetadataOnly, 1024);
4178        let knowledge = KnowledgeBase::new();
4179
4180        let receipt = adapter
4181            .execute_command(&command(&url), &knowledge)
4182            .await
4183            .unwrap();
4184        let evidence = receipt.after_execution().evidence();
4185
4186        assert_eq!(
4187            value(evidence, HttpEvidencePredicate::RESPONSE_STATUS),
4188            Some(&EvidenceValue::Unsigned(429))
4189        );
4190        assert_eq!(
4191            value(evidence, HttpEvidencePredicate::RATE_LIMIT_DETECTED),
4192            Some(&EvidenceValue::Boolean(true))
4193        );
4194        assert_eq!(
4195            value(evidence, HttpEvidencePredicate::RATE_LIMIT_ADVERTISED),
4196            Some(&EvidenceValue::Boolean(true))
4197        );
4198        assert_eq!(
4199            value(evidence, HttpEvidencePredicate::RATE_LIMIT_RETRY_AFTER),
4200            Some(&EvidenceValue::Unsigned(7))
4201        );
4202        assert_eq!(
4203            value(evidence, HttpEvidencePredicate::RATE_LIMIT_REMAINING),
4204            Some(&EvidenceValue::Unsigned(3))
4205        );
4206        assert_eq!(
4207            value(evidence, HttpEvidencePredicate::RATE_LIMIT_LIMIT),
4208            Some(&EvidenceValue::Unsigned(100))
4209        );
4210    }
4211
4212    #[tokio::test]
4213    async fn redirect_is_observed_without_following_the_location() {
4214        let url = serve_once(
4215            b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:9/outside\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
4216        )
4217        .await;
4218        let policy = HttpEvidencePolicy::new([url.clone()], Duration::from_secs(2), 1024)
4219            .unwrap()
4220            .with_body_capture(HttpBodyCapture::MetadataOnly)
4221            .unwrap();
4222        let (adapter, accounting) = metered_adapter(
4223            &url,
4224            policy,
4225            RuntimeBudget::default().with_max_total_requests(1),
4226        );
4227        let knowledge = KnowledgeBase::new();
4228
4229        let receipt = adapter
4230            .execute_command(&command(&url), &knowledge)
4231            .await
4232            .unwrap();
4233        let evidence = receipt.after_execution().evidence();
4234
4235        assert_eq!(
4236            value(evidence, HttpEvidencePredicate::RESPONSE_STATUS),
4237            Some(&EvidenceValue::Unsigned(302))
4238        );
4239        assert_eq!(
4240            value(
4241                evidence,
4242                HttpEvidencePredicate::response_header("location").unwrap(),
4243            ),
4244            Some(&EvidenceValue::Text(
4245                "http://127.0.0.1:9/outside".to_owned()
4246            ))
4247        );
4248        assert_eq!(
4249            value(evidence, HttpEvidencePredicate::RESPONSE_FINAL_URL),
4250            Some(&EvidenceValue::Text(url.to_string()))
4251        );
4252        assert_eq!(accounting.snapshot().total_requests(), 1);
4253        assert_eq!(accounting.snapshot().response_bytes(), 0);
4254        let audit = accounting.dispatch_audit();
4255        assert_eq!(audit.omitted_receipt_count(), 0);
4256        let dispatch = audit.receipts().first().unwrap();
4257        assert_eq!(dispatch.sequence(), 0);
4258        assert_eq!(dispatch.action_id(), "http.probe");
4259        assert_eq!(dispatch.stage(), DecisionExecutionStage::Passive);
4260        assert_eq!(dispatch.origin(), Some(DecisionActionOrigin::Planned));
4261        assert_eq!(dispatch.response_bytes(), 0);
4262        assert_eq!(dispatch.outcome(), TransportDispatchOutcome::Completed);
4263    }
4264
4265    #[tokio::test]
4266    async fn executor_rejects_out_of_scope_provider_target_before_io() {
4267        let allowed = Url::parse("http://127.0.0.1:1/").unwrap();
4268        let outside = Url::parse("http://127.0.0.1:2/").unwrap();
4269        let provider: Arc<dyn HttpProbeProvider> =
4270            Arc::new(move |_request: &DecisionExecutionRequest| {
4271                HttpProbe::new(outside.clone(), HttpProbeMethod::Get)
4272            });
4273        let policy = HttpEvidencePolicy::for_origin(allowed.clone()).unwrap();
4274        let accounting = RequestAccountingBroker::new(RuntimeBudget::default());
4275        let executor =
4276            HttpEvidenceExecutor::new_with_accounting(policy, provider, accounting.clone())
4277                .unwrap();
4278        let mut registry = DecisionExecutorRegistry::new();
4279        registry.register(Arc::new(executor)).unwrap();
4280        let adapter = DecisionRunnerAdapter::new(registry);
4281        let knowledge = KnowledgeBase::new();
4282
4283        let error = adapter
4284            .execute_command(&command(&allowed), &knowledge)
4285            .await
4286            .unwrap_err();
4287
4288        let failure = error.execution_failure().unwrap();
4289        assert_eq!(
4290            failure.kind(),
4291            DecisionExecutionFailureKind::BlockedByPolicy
4292        );
4293        assert_eq!(failure.executor_id(), HTTP_EVIDENCE_EXECUTOR_ID);
4294        assert_eq!(failure.action_id(), "http.probe");
4295        assert!(failure.diagnostic().contains("outside policy"));
4296        assert_eq!(knowledge.stats().evidence, 0);
4297        assert_eq!(accounting.snapshot().total_requests(), 0);
4298        assert_eq!(accounting.snapshot().response_bytes(), 0);
4299    }
4300
4301    #[tokio::test]
4302    async fn provider_timeout_is_classified_without_parsing_its_diagnostic() {
4303        let allowed = Url::parse("http://127.0.0.1:1/").unwrap();
4304        let provider: Arc<dyn HttpProbeProvider> =
4305            Arc::new(|_request: &DecisionExecutionRequest| {
4306                Err(HttpEvidenceError::Timeout { timeout_ms: 25 })
4307            });
4308        let policy = HttpEvidencePolicy::for_origin(allowed.clone()).unwrap();
4309        let executor = HttpEvidenceExecutor::new(policy, provider).unwrap();
4310        let mut registry = DecisionExecutorRegistry::new();
4311        registry.register(Arc::new(executor)).unwrap();
4312        let adapter = DecisionRunnerAdapter::new(registry);
4313        let knowledge = KnowledgeBase::new();
4314
4315        let error = adapter
4316            .execute_command(&command(&allowed), &knowledge)
4317            .await
4318            .unwrap_err();
4319
4320        let failure = error.execution_failure().unwrap();
4321        assert_eq!(failure.kind(), DecisionExecutionFailureKind::RequestTimeout);
4322        assert_eq!(failure.executor_id(), HTTP_EVIDENCE_EXECUTOR_ID);
4323        assert_eq!(failure.action_id(), "http.probe");
4324        assert_eq!(
4325            failure.diagnostic(),
4326            "HTTP evidence request timed out after 25 ms"
4327        );
4328        assert_eq!(knowledge.stats().evidence, 0);
4329    }
4330
4331    #[tokio::test]
4332    async fn metered_dispatch_failure_charges_one_request_without_response_bytes() {
4333        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4334        let address = listener.local_addr().unwrap();
4335        drop(listener);
4336        let url = Url::parse(&format!("http://{address}/probe")).unwrap();
4337        let policy = HttpEvidencePolicy::new(
4338            [url.clone()],
4339            Duration::from_millis(500),
4340            DEFAULT_HTTP_BODY_LIMIT,
4341        )
4342        .unwrap();
4343        let (adapter, accounting) = metered_adapter(&url, policy, RuntimeBudget::default());
4344        let knowledge = KnowledgeBase::new();
4345
4346        let error = adapter
4347            .execute_command(&command(&url), &knowledge)
4348            .await
4349            .unwrap_err();
4350
4351        let failure = error.execution_failure().unwrap();
4352        assert!(matches!(
4353            failure.kind(),
4354            DecisionExecutionFailureKind::TransportFailure
4355                | DecisionExecutionFailureKind::RequestTimeout
4356        ));
4357        assert_eq!(accounting.snapshot().total_requests(), 1);
4358        assert_eq!(accounting.snapshot().response_bytes(), 0);
4359        assert_eq!(knowledge.stats().evidence, 0);
4360        let audit = accounting.dispatch_audit();
4361        let dispatch = audit.receipts().first().unwrap();
4362        assert_eq!(dispatch.response_bytes(), 0);
4363        assert!(matches!(
4364            dispatch.outcome(),
4365            TransportDispatchOutcome::TransportFailure | TransportDispatchOutcome::RequestTimeout
4366        ));
4367    }
4368
4369    #[tokio::test]
4370    async fn protocol_failure_is_not_implicitly_retried() {
4371        let server = serve_empty_response_then_watch_for_retry().await;
4372        let url = server.target();
4373        let policy = HttpEvidencePolicy::new(
4374            [url.clone()],
4375            Duration::from_secs(1),
4376            DEFAULT_HTTP_BODY_LIMIT,
4377        )
4378        .unwrap();
4379        let (adapter, accounting) = metered_adapter(&url, policy, RuntimeBudget::default());
4380        let knowledge = KnowledgeBase::new();
4381
4382        let error = adapter
4383            .execute_command(&command(&url), &knowledge)
4384            .await
4385            .unwrap_err();
4386        tokio::time::sleep(Duration::from_millis(300)).await;
4387
4388        assert_eq!(
4389            error.execution_failure().unwrap().kind(),
4390            DecisionExecutionFailureKind::TransportFailure
4391        );
4392        assert_eq!(accounting.snapshot().total_requests(), 1);
4393        assert_eq!(server.requests(), 1);
4394        assert_eq!(knowledge.stats().evidence, 0);
4395        assert_eq!(
4396            accounting.dispatch_audit().receipts()[0].outcome(),
4397            TransportDispatchOutcome::TransportFailure
4398        );
4399    }
4400
4401    #[tokio::test]
4402    async fn metered_partial_body_timeout_keeps_already_retained_bytes() {
4403        let url = serve_partial_then_stall(
4404            b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 10\r\nConnection: close\r\n\r\n0123",
4405        )
4406        .await;
4407        let policy = HttpEvidencePolicy::new(
4408            [url.clone()],
4409            Duration::from_millis(100),
4410            DEFAULT_HTTP_BODY_LIMIT,
4411        )
4412        .unwrap();
4413        let (adapter, accounting) = metered_adapter(&url, policy, RuntimeBudget::default());
4414        let knowledge = KnowledgeBase::new();
4415
4416        let error = adapter
4417            .execute_command(&command(&url), &knowledge)
4418            .await
4419            .unwrap_err();
4420
4421        let failure = error.execution_failure().unwrap();
4422        assert_eq!(failure.kind(), DecisionExecutionFailureKind::RequestTimeout);
4423        assert!(failure.diagnostic().contains("timed out"));
4424        assert_eq!(accounting.snapshot().total_requests(), 1);
4425        assert_eq!(accounting.snapshot().response_bytes(), 4);
4426        assert_eq!(knowledge.stats().evidence, 0);
4427        let audit = accounting.dispatch_audit();
4428        let dispatch = audit.receipts().first().unwrap();
4429        assert_eq!(dispatch.response_bytes(), 4);
4430        assert_eq!(dispatch.outcome(), TransportDispatchOutcome::RequestTimeout);
4431    }
4432
4433    #[tokio::test]
4434    async fn metered_body_is_clamped_while_full_transport_chunk_is_accounted() {
4435        let url = serve_once(
4436            b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 10\r\nConnection: close\r\n\r\n0123456789",
4437        )
4438        .await;
4439        let policy = HttpEvidencePolicy::new(
4440            [url.clone()],
4441            Duration::from_secs(2),
4442            DEFAULT_HTTP_BODY_LIMIT,
4443        )
4444        .unwrap();
4445        let budget = RuntimeBudget::default().with_max_response_bytes(4);
4446        let (adapter, accounting) = metered_adapter(&url, policy, budget);
4447        let knowledge = KnowledgeBase::new();
4448
4449        let receipt = adapter
4450            .execute_command(&command(&url), &knowledge)
4451            .await
4452            .unwrap();
4453
4454        assert_eq!(
4455            value(
4456                receipt.evidence(),
4457                HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
4458            ),
4459            Some(&EvidenceValue::Unsigned(4))
4460        );
4461        assert_eq!(
4462            value(
4463                receipt.evidence(),
4464                HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
4465            ),
4466            Some(&EvidenceValue::Boolean(true))
4467        );
4468        assert_eq!(accounting.snapshot().total_requests(), 1);
4469        let observed = accounting.snapshot().response_bytes();
4470        assert!(
4471            (5..=10).contains(&observed),
4472            "broker must charge the complete chunk that crosses the four-byte retention limit; observed {observed}"
4473        );
4474        let audit = accounting.dispatch_audit();
4475        let dispatch = &audit.receipts()[0];
4476        assert_eq!(dispatch.response_bytes(), observed);
4477        assert_eq!(
4478            dispatch.outcome(),
4479            TransportDispatchOutcome::ResponseBudgetReached
4480        );
4481    }
4482
4483    #[tokio::test]
4484    async fn collector_does_not_read_another_chunk_after_budget_is_exactly_full() {
4485        let (url, first_body_chunk, release_second_chunk) = serve_split_body_after_release().await;
4486        let budget = RuntimeBudget::default().with_max_response_bytes(4);
4487        let policy = HttpEvidencePolicy::for_origin(url.clone()).unwrap();
4488        let (adapter, accounting) = metered_adapter(&url, policy, budget);
4489        let target = url.clone();
4490        let mut execution = tokio::spawn(async move {
4491            adapter
4492                .execute_command(&command(&target), &KnowledgeBase::new())
4493                .await
4494        });
4495
4496        tokio::time::timeout(Duration::from_secs(1), first_body_chunk)
4497            .await
4498            .expect("server did not deliver the first body chunk")
4499            .unwrap();
4500        let completed = tokio::time::timeout(Duration::from_secs(1), &mut execution).await;
4501        let _ = release_second_chunk.send(());
4502        let receipt = completed
4503            .expect("collector waited for a chunk after the response budget was full")
4504            .unwrap()
4505            .unwrap();
4506
4507        assert_eq!(accounting.snapshot().response_bytes(), 4);
4508        assert_eq!(
4509            value(
4510                receipt.evidence(),
4511                HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
4512            ),
4513            Some(&EvidenceValue::Unsigned(4))
4514        );
4515        assert_eq!(
4516            value(
4517                receipt.evidence(),
4518                HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
4519            ),
4520            Some(&EvidenceValue::Boolean(true))
4521        );
4522    }
4523
4524    #[tokio::test]
4525    async fn metered_runtime_limit_is_preserved_without_dispatch() {
4526        let url =
4527            serve_once(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await;
4528        let policy = HttpEvidencePolicy::for_origin(url.clone()).unwrap();
4529        let budget = RuntimeBudget::default().with_max_total_requests(0);
4530        let (adapter, accounting) = metered_adapter(&url, policy, budget);
4531        let knowledge = KnowledgeBase::new();
4532
4533        let error = adapter
4534            .execute_command(&command(&url), &knowledge)
4535            .await
4536            .unwrap_err();
4537
4538        let limit = error.runtime_limit().unwrap();
4539        assert_eq!(limit.dimension(), RuntimeBudgetDimension::TotalRequests);
4540        assert_eq!(accounting.snapshot().total_requests(), 0);
4541        assert_eq!(accounting.snapshot().response_bytes(), 0);
4542        assert_eq!(knowledge.stats().evidence, 0);
4543        assert!(accounting.dispatch_audit().is_empty());
4544    }
4545
4546    #[tokio::test]
4547    async fn buffered_request_body_is_charged_and_denied_before_socket() {
4548        let response = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
4549        let allowed_server = serve_counted(response).await;
4550        let allowed_target = allowed_server.target();
4551        let (allowed_adapter, allowed_accounting) = buffered_adapter(
4552            &allowed_target,
4553            RuntimeBudget::default().with_max_request_body_bytes(9),
4554            b"candidate".to_vec(),
4555        );
4556
4557        allowed_adapter
4558            .execute_command(&command(&allowed_target), &KnowledgeBase::new())
4559            .await
4560            .unwrap();
4561        assert_eq!(allowed_server.requests(), 1);
4562        assert_eq!(allowed_accounting.snapshot().total_requests(), 1);
4563        assert_eq!(allowed_accounting.snapshot().request_body_bytes(), 9);
4564
4565        let denied_server = serve_counted(response).await;
4566        let denied_target = denied_server.target();
4567        let (denied_adapter, denied_accounting) = buffered_adapter(
4568            &denied_target,
4569            RuntimeBudget::default().with_max_request_body_bytes(8),
4570            b"candidate".to_vec(),
4571        );
4572        let error = denied_adapter
4573            .execute_command(&command(&denied_target), &KnowledgeBase::new())
4574            .await
4575            .unwrap_err();
4576        tokio::time::sleep(Duration::from_millis(25)).await;
4577
4578        assert_eq!(
4579            error.runtime_limit().unwrap().dimension(),
4580            RuntimeBudgetDimension::RequestBodyBytes
4581        );
4582        assert_eq!(denied_server.requests(), 0);
4583        assert_eq!(denied_accounting.snapshot().total_requests(), 0);
4584        assert_eq!(denied_accounting.snapshot().request_body_bytes(), 0);
4585        assert!(denied_accounting.dispatch_audit().is_empty());
4586    }
4587
4588    #[tokio::test]
4589    async fn failed_buffered_request_stays_charged_and_retry_cannot_escape_budget() {
4590        let server = serve_empty_response_then_watch_for_retry().await;
4591        let target = server.target();
4592        let (adapter, accounting) = buffered_adapter(
4593            &target,
4594            RuntimeBudget::default()
4595                .with_max_total_requests(2)
4596                .with_max_request_body_bytes(4),
4597            b"body".to_vec(),
4598        );
4599        let knowledge = KnowledgeBase::new();
4600
4601        let first = adapter
4602            .execute_command(&command(&target), &knowledge)
4603            .await
4604            .unwrap_err();
4605        assert_eq!(
4606            first.execution_failure().unwrap().kind(),
4607            DecisionExecutionFailureKind::TransportFailure
4608        );
4609        assert_eq!(accounting.snapshot().total_requests(), 1);
4610        assert_eq!(accounting.snapshot().request_body_bytes(), 4);
4611
4612        let retry = adapter
4613            .execute_command(&command(&target), &knowledge)
4614            .await
4615            .unwrap_err();
4616        tokio::time::sleep(Duration::from_millis(300)).await;
4617        assert_eq!(
4618            retry.runtime_limit().unwrap().dimension(),
4619            RuntimeBudgetDimension::RequestBodyBytes
4620        );
4621        assert_eq!(server.requests(), 1);
4622        assert_eq!(accounting.snapshot().total_requests(), 1);
4623        assert_eq!(accounting.snapshot().request_body_bytes(), 4);
4624        assert_eq!(knowledge.stats().evidence, 0);
4625        let audit = accounting.dispatch_audit();
4626        assert_eq!(audit.receipts().len(), 1);
4627        assert_eq!(audit.receipts()[0].request_body_bytes(), 4);
4628        assert_eq!(
4629            audit.receipts()[0].outcome(),
4630            TransportDispatchOutcome::TransportFailure
4631        );
4632    }
4633
4634    #[tokio::test]
4635    async fn multi_request_executor_cannot_exceed_budget() {
4636        let server =
4637            serve_counted(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
4638                .await;
4639        let target = server.target();
4640        let policy = HttpEvidencePolicy::for_origin(target.clone()).unwrap();
4641        let accounting =
4642            RequestAccountingBroker::new(RuntimeBudget::default().with_max_total_requests(1));
4643        let requests = HttpRequestBroker::new_metered(policy, accounting.clone()).unwrap();
4644        let mut registry = DecisionExecutorRegistry::new();
4645        registry
4646            .register(Arc::new(MultiRequestExecutor {
4647                requests,
4648                target: target.clone(),
4649            }))
4650            .unwrap();
4651        let adapter = DecisionRunnerAdapter::new(registry);
4652        let knowledge = KnowledgeBase::new();
4653
4654        let error = adapter
4655            .execute_command(&command(&target), &knowledge)
4656            .await
4657            .unwrap_err();
4658
4659        let failure = error.execution_failure().unwrap();
4660        assert_eq!(failure.action_id(), "http.probe");
4661        assert_eq!(failure.stage(), DecisionExecutionStage::Passive);
4662        assert_eq!(failure.origin(), Some(DecisionActionOrigin::Planned));
4663        let limit = failure.runtime_limit().unwrap();
4664        assert_eq!(limit.dimension(), RuntimeBudgetDimension::TotalRequests);
4665        assert_eq!(limit.limit(), 1);
4666        assert_eq!(limit.observed(), 2);
4667        assert_eq!(accounting.snapshot().total_requests(), 1);
4668        assert_eq!(server.requests(), 1);
4669        assert_eq!(knowledge.stats().evidence, 0);
4670        let audit = accounting.dispatch_audit();
4671        assert_eq!(audit.receipts().len(), 1);
4672        assert_eq!(audit.receipts()[0].sequence(), 0);
4673        assert_eq!(audit.receipts()[0].action_id(), "http.probe");
4674        assert_eq!(
4675            audit.receipts()[0].outcome(),
4676            TransportDispatchOutcome::Completed
4677        );
4678    }
4679
4680    #[tokio::test]
4681    async fn multi_request_active_executor_cannot_exceed_active_budget() {
4682        let server =
4683            serve_counted(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
4684                .await;
4685        let target = server.target();
4686        let policy = HttpEvidencePolicy::for_origin(target.clone()).unwrap();
4687        let accounting =
4688            RequestAccountingBroker::new(RuntimeBudget::default().with_max_active_verifications(1));
4689        let requests = HttpRequestBroker::new_metered(policy, accounting.clone()).unwrap();
4690        let mut registry = DecisionExecutorRegistry::new();
4691        registry
4692            .register(Arc::new(MultiRequestExecutor {
4693                requests,
4694                target: target.clone(),
4695            }))
4696            .unwrap();
4697        registry
4698            .route_action(
4699                DecisionExecutionStage::Active,
4700                "http.probe",
4701                HTTP_EVIDENCE_EXECUTOR_ID,
4702            )
4703            .unwrap();
4704        let adapter = DecisionRunnerAdapter::new(registry);
4705        let knowledge = KnowledgeBase::new();
4706        let case = VerificationCase::new(
4707            "case:http:active",
4708            EntityId::new(format!("endpoint:{target}")).unwrap(),
4709            "http.probe",
4710            "hypothesis:http",
4711        )
4712        .unwrap();
4713
4714        let error = adapter
4715            .execute_command(
4716                &DecisionLoopCommand::CollectActiveEvidence { case },
4717                &knowledge,
4718            )
4719            .await
4720            .unwrap_err();
4721
4722        let failure = error.execution_failure().unwrap();
4723        assert_eq!(failure.stage(), DecisionExecutionStage::Active);
4724        let limit = failure.runtime_limit().unwrap();
4725        assert_eq!(
4726            limit.dimension(),
4727            RuntimeBudgetDimension::ActiveVerifications
4728        );
4729        assert_eq!(limit.limit(), 1);
4730        assert_eq!(limit.observed(), 2);
4731        assert_eq!(accounting.snapshot().total_requests(), 1);
4732        assert_eq!(accounting.snapshot().active_verifications(), 1);
4733        assert_eq!(server.requests(), 1);
4734        assert_eq!(knowledge.stats().evidence, 0);
4735        let audit = accounting.dispatch_audit();
4736        assert_eq!(audit.receipts().len(), 1);
4737        assert_eq!(audit.receipts()[0].stage(), DecisionExecutionStage::Active);
4738        assert_eq!(audit.receipts()[0].origin(), None);
4739        assert_eq!(
4740            audit.receipts()[0].outcome(),
4741            TransportDispatchOutcome::Completed
4742        );
4743    }
4744
4745    #[test]
4746    fn probe_and_policy_reject_ambiguous_or_unbounded_inputs() {
4747        let url = Url::parse("https://example.test/").unwrap();
4748        assert!(matches!(
4749            HttpProbe::new(url.clone(), HttpProbeMethod::Get)
4750                .unwrap()
4751                .with_header("Host", "other.test"),
4752            Err(HttpEvidenceError::ForbiddenRequestHeader { .. })
4753        ));
4754        assert!(matches!(
4755            HttpEvidencePolicy::new([url.clone()], Duration::ZERO, 1024),
4756            Err(HttpEvidenceError::ZeroTimeout)
4757        ));
4758        assert!(matches!(
4759            HttpEvidencePolicy::for_origin(url.clone())
4760                .unwrap()
4761                .with_reliability(ConfidenceScore::NONE),
4762            Err(HttpEvidenceError::ZeroReliability)
4763        ));
4764        assert!(matches!(
4765            HttpEvidencePolicy::new([url], Duration::from_secs(1), MAX_HTTP_BODY_LIMIT + 1),
4766            Err(HttpEvidenceError::BodyLimitTooLarge { .. })
4767        ));
4768    }
4769}