1use 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
110pub 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#[cfg(feature = "wordpress-review")]
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub(crate) enum WordPressRestIndexAdvertisement {
129 Missing,
130 Candidate(Url),
131 InvalidOrAmbiguous,
132}
133
134#[derive(Debug, Error)]
136#[non_exhaustive]
137pub enum HttpEvidenceError {
138 #[error("HTTP evidence policy must contain at least one allowed origin")]
140 EmptyAllowedOrigins,
141
142 #[error("HTTP evidence request timeout must be greater than zero")]
144 ZeroTimeout,
145
146 #[error("HTTP evidence body limit must be greater than zero")]
148 ZeroBodyLimit,
149
150 #[error("HTTP evidence reliability must be greater than zero")]
152 ZeroReliability,
153
154 #[error("HTTP evidence body limit {actual} exceeds maximum {maximum}")]
156 BodyLimitTooLarge { actual: usize, maximum: usize },
157
158 #[error("HTTP evidence text sample limit must be greater than zero")]
160 ZeroTextSampleLimit,
161
162 #[error("HTTP text sample limit {max_chars} exceeds body byte limit {max_body_bytes}")]
164 TextSampleLimitTooLarge {
165 max_chars: usize,
167 max_body_bytes: usize,
169 },
170
171 #[error("unsupported HTTP evidence URL scheme {scheme}")]
173 UnsupportedScheme { scheme: String },
174
175 #[error("HTTP evidence URL must not contain embedded credentials")]
177 EmbeddedCredentials,
178
179 #[error("decision subject {subject} is not an endpoint URL identity")]
181 InvalidEndpointSubject { subject: String },
182
183 #[error("HTTP evidence executor id must not be empty")]
185 EmptyExecutorId,
186
187 #[error("invalid HTTP evidence URL {value}: {source}")]
189 InvalidUrl {
190 value: String,
192 #[source]
194 source: url::ParseError,
195 },
196
197 #[error("invalid HTTP header name {name}")]
199 InvalidHeaderName { name: String },
200
201 #[error("invalid value for HTTP request header {name}")]
203 InvalidHeaderValue { name: String },
204
205 #[error("HTTP request body length is unavailable to the accounting broker")]
207 UnmeteredRequestBody,
208
209 #[cfg(feature = "graphql-review")]
211 #[error("GraphQL review request body exceeds its compiled byte limit")]
212 GraphqlReviewRequestBodyLimit,
213
214 #[cfg(feature = "wordpress-review")]
217 #[error("WordPress metadata request violated its closed admission contract")]
218 InvalidWordPressMetadataRequest,
219
220 #[cfg(feature = "wordpress-review")]
223 #[error("WordPress asset request violated its closed admission contract")]
224 InvalidWordPressAssetRequest,
225
226 #[cfg(feature = "xml-external-entity-review")]
229 #[error("controlled XML request violated its closed admission contract")]
230 InvalidXmlExternalEntityRequest,
231
232 #[error("HTTP request header {name} is forbidden by evidence policy")]
234 ForbiddenRequestHeader { name: String },
235
236 #[error("HTTP evidence target origin is outside policy: {url}")]
238 TargetOutsidePolicy { url: String },
239
240 #[error("payload strategy {strategy} is not registered for this executor")]
242 PayloadStrategyUnavailable {
243 strategy: String,
245 },
246
247 #[error("payload strategy {strategy} failed to derive a {role} artifact")]
249 PayloadDerivationFailed {
250 strategy: String,
252 role: &'static str,
254 },
255
256 #[error("HTTP query payload parameter is invalid")]
258 InvalidQueryPayloadParameter,
259
260 #[error("HTTP query payload binding requires a query-free base probe")]
263 QueryPayloadRequiresQueryFreeProbe,
264
265 #[error("failed to construct HTTP evidence client: {0}")]
267 Client(#[source] reqwest::Error),
268
269 #[error("HTTP evidence request timed out after {timeout_ms} ms")]
271 Timeout { timeout_ms: u64 },
272
273 #[error("HTTP evidence request failed: {0}")]
275 Request(#[source] reqwest::Error),
276
277 #[error("failed to construct HTTP evidence: {0}")]
279 Reasoning(#[from] termivar_core::ReasoningModelError),
280
281 #[error("assessment defense projection violated its fixed evidence boundary")]
284 AssessmentDefenseProjectionInvariant,
285
286 #[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
347fn payload_role_name(role: PayloadVariantRole) -> &'static str {
349 match role {
350 PayloadVariantRole::Control => "control",
351 PayloadVariantRole::Candidate => "candidate",
352 }
353}
354
355#[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 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 pub fn reference(&self) -> &PayloadStrategyRef {
415 &self.reference
416 }
417
418 pub fn header(&self) -> &str {
420 &self.header
421 }
422
423 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 if value.is_empty() {
455 return Ok(probe);
456 }
457 probe.with_header(self.header.clone(), value)
458 }
459}
460
461#[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
578pub 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 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 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 pub(crate) fn with_form_control_capture(mut self) -> Self {
699 self.capture_form_control_names = true;
700 self
701 }
702
703 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 pub(crate) fn with_assessment_defense_projection(mut self) -> Self {
723 self.assessment_defense_projection = true;
724 self
725 }
726
727 pub fn with_payload_binding(mut self, binding: HttpHeaderPayloadBinding) -> Self {
735 self.payload = Some(HttpPayloadBinding::Header(binding));
736 self
737 }
738
739 pub(crate) fn with_query_payload_binding(mut self, binding: HttpQueryPayloadBinding) -> Self {
743 self.payload = Some(HttpPayloadBinding::Query(binding));
744 self
745 }
746
747 pub fn payload_strategy_reference(&self) -> Option<&PayloadStrategyRef> {
749 self.payload.as_ref().map(HttpPayloadBinding::reference)
750 }
751
752 pub fn policy(&self) -> &HttpEvidencePolicy {
754 self.requests.policy()
755 }
756
757 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 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 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 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 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 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 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 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
1241mod 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 fn observe(
1263 &self,
1264 observation: CompleteHttpResponseObservation<'_>,
1265 ) -> Result<(Vec<Evidence>, ResponseBodyDigestDisposition), HttpEvidenceError>;
1266}
1267
1268pub(crate) enum ResponseBodyDigestDisposition {
1272 Retain,
1273 Suppress { lineage_evidence_id: EvidenceId },
1274}
1275
1276pub(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 #[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#[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
1905fn 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#[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 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 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
2312pub(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 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 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 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 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 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 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 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 assert_eq!(names, Vec::<String>::new());
2683 }
2684
2685 #[test]
2686 fn form_control_extraction_observes_html_integration_points_only() {
2687 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 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 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 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 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 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 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 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 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 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 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 }
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 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 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 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 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 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 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 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 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}