Skip to main content

termivar_scanner/web_runtime/api_visibility/differential/
execution.rs

1//! Metered execution for one host-authorized API visibility pair.
2
3use sha2::{Digest, Sha256};
4use termivar_core::{ApiSurfaceKind, ApiVisibilityPairKind};
5
6use super::super::super::{await_execution, RuntimeExecution, StandardWebDecisionRuntime};
7use super::{
8    encode_digest, update_framed, ApiVisibilityDifferentialAudit,
9    ApiVisibilityDifferentialDisposition, ApiVisibilityDifferentialRequest,
10    ApiVisibilityInconclusiveReason, ApiVisibilityLeg, ApiVisibilityLegReceipt,
11    RuntimeApiVisibilityExecutionError, RuntimeApiVisibilityRunReport,
12};
13use crate::{
14    api_observation::{api_visibility_review_for_commit, ingest_api_visibility_observation},
15    ApiObservationReceipt, ApiVisibilityComparator, ApiVisibilityReview,
16    ApiVisibilityReviewDisposition, DecisionExecutionClass, DecisionExecutionFailureKind,
17    DecisionExecutionStage, ProfiledApiVisibilityComparison, ProfiledApiVisibilityError,
18    RuntimeLimitExceeded,
19};
20
21const CONTROL_ACTION_ID: &str = "api.visibility.authorization-context.control";
22const CANDIDATE_ACTION_ID: &str = "api.visibility.authorization-context.candidate";
23const OBSERVATION_COMPONENT: &str = "runtime.api-visibility-differential";
24const RESPONSE_DIGEST_DOMAIN: &[u8] = b"venom.api-visibility.response-body.v1\0";
25
26impl StandardWebDecisionRuntime {
27    /// Executes one authorized, same-target JSON authorization-context pair.
28    ///
29    /// Both legs use isolated broker pools backed by the runtime's shared
30    /// accounting authority, are charged as active verifications, and are
31    /// independently cancellation/budget bounded. Only
32    /// two complete JSON views reach Comparator V3 and atomic observation
33    /// ingestion. A difference can yield
34    /// [`ApiVisibilityDifferentialDisposition::AwaitHumanReview`], never a
35    /// vulnerability verdict or decision-loop success.
36    ///
37    /// Once pre-I/O configuration and target checks pass, this operation
38    /// consumes the runtime's single-use execution right even on cancellation
39    /// or transport failure. Use another runtime instance for the standard
40    /// endpoint analysis workflow.
41    ///
42    /// # Examples
43    ///
44    /// ```rust,no_run
45    /// use url::Url;
46    /// use termivar_scanner::{
47    ///     ApiComparisonProfile, ApiVisibilityContextProbe,
48    ///     ApiVisibilityDifferentialRequest, ApiVisibilityDimension, EntityId,
49    ///     HttpProbe, HttpProbeMethod, StandardWebDecisionRuntime,
50    /// };
51    ///
52    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
53    /// let target = Url::parse("https://example.test/api/account")?;
54    /// let control = HttpProbe::new(target.clone(), HttpProbeMethod::Get)?
55    ///     .with_header("authorization", "Bearer control-context")?;
56    /// let candidate = HttpProbe::new(target.clone(), HttpProbeMethod::Get)?
57    ///     .with_header("authorization", "Bearer candidate-context")?;
58    /// let request = ApiVisibilityDifferentialRequest::new(
59    ///     "comparison-1",
60    ///     EntityId::new("resource:account")?,
61    ///     ApiVisibilityContextProbe::new("context:control", control)?,
62    ///     ApiVisibilityContextProbe::new("context:candidate", candidate)?,
63    ///     ["authorization"],
64    ///     ApiComparisonProfile::default(),
65    ///     ApiVisibilityDimension::Fields,
66    ///     1_800_000_000_000,
67    /// )?;
68    /// let mut runtime = StandardWebDecisionRuntime::builder(target)
69    ///     .enable_api_reasoning()
70    ///     .build()?;
71    ///
72    /// let report = runtime.run_api_visibility_pair(request).await?;
73    /// assert_eq!(report.audit().usage().total_requests(), 2);
74    /// # Ok(())
75    /// # }
76    /// ```
77    pub async fn run_api_visibility_pair(
78        &mut self,
79        request: ApiVisibilityDifferentialRequest,
80    ) -> Result<RuntimeApiVisibilityRunReport, RuntimeApiVisibilityExecutionError> {
81        if self.started {
82            return Err(RuntimeApiVisibilityExecutionError::AlreadyStarted);
83        }
84        if self.api_reasoning_installation.is_none() {
85            return Err(RuntimeApiVisibilityExecutionError::ApiReasoningDisabled);
86        }
87        if request.control.probe.url() != &self.target
88            || request.candidate.probe.url() != &self.target
89        {
90            return Err(RuntimeApiVisibilityExecutionError::RuntimeTargetMismatch);
91        }
92
93        // Each principal receives a distinct connection pool. Accounting and
94        // policy remain shared, but connection-bound server state cannot flow
95        // from the control request into the candidate request.
96        let control_requests = self
97            .authority
98            .requests()
99            .isolated()
100            .map_err(|source| RuntimeApiVisibilityExecutionError::TransportSetup { source })?;
101        let candidate_requests = self
102            .authority
103            .requests()
104            .isolated()
105            .map_err(|source| RuntimeApiVisibilityExecutionError::TransportSetup { source })?;
106        let evidence_reliability = self.authority.requests().policy().reliability();
107
108        self.started = true;
109        let timing = self.authority.start();
110        let started_at = timing.started_at();
111        let deadline = timing.deadline();
112        let mut audit = ApiVisibilityDifferentialAudit::for_request(&request);
113        if self.authority.cancellation().is_cancelled() {
114            return Ok(self.visibility_cancelled_report(audit, None, started_at));
115        }
116
117        let control_limits = match self.reserve_execution(
118            CONTROL_ACTION_ID,
119            DecisionExecutionStage::Active,
120            DecisionExecutionClass::TransportBound,
121            started_at,
122        ) {
123            Ok(limits) => limits,
124            Err(limit) => {
125                return Ok(self.visibility_limit_report(
126                    audit,
127                    Some(ApiVisibilityLeg::Control),
128                    limit,
129                    started_at,
130                ));
131            },
132        };
133        let control_result = await_execution(
134            self.authority.cancellation(),
135            deadline,
136            control_requests.collect_for_runtime(
137                CONTROL_ACTION_ID,
138                DecisionExecutionStage::Active,
139                None,
140                control_limits,
141                &request.control.probe,
142            ),
143        )
144        .await;
145        let control_response = match control_result {
146            RuntimeExecution::Completed(Ok(response)) => response,
147            RuntimeExecution::Completed(Err(error)) => {
148                return Ok(self.visibility_collection_report(
149                    audit,
150                    ApiVisibilityLeg::Control,
151                    error,
152                    started_at,
153                ));
154            },
155            RuntimeExecution::Cancelled => {
156                return Ok(self.visibility_cancelled_report(
157                    audit,
158                    Some(ApiVisibilityLeg::Control),
159                    started_at,
160                ));
161            },
162            RuntimeExecution::WallTimeExceeded => {
163                let limit = self.wall_limit(started_at);
164                return Ok(self.visibility_limit_report(
165                    audit,
166                    Some(ApiVisibilityLeg::Control),
167                    limit,
168                    started_at,
169                ));
170            },
171        };
172        audit.control = Some(leg_receipt(
173            ApiVisibilityLeg::Control,
174            &control_response,
175            &request.request_template_sha256,
176        ));
177        self.refresh_elapsed(started_at);
178        if let Some(limit) = self.response_limit_if_exceeded(CONTROL_ACTION_ID) {
179            return Ok(self.visibility_limit_report(
180                audit,
181                Some(ApiVisibilityLeg::Control),
182                limit,
183                started_at,
184            ));
185        }
186        if self.authority.cancellation().is_cancelled() {
187            return Ok(self.visibility_cancelled_report(
188                audit,
189                Some(ApiVisibilityLeg::Control),
190                started_at,
191            ));
192        }
193        if let Some(limit) = self.wall_limit_if_reached(started_at) {
194            return Ok(self.visibility_limit_report(
195                audit,
196                Some(ApiVisibilityLeg::Control),
197                limit,
198                started_at,
199            ));
200        }
201        let control_document = match response_document(&control_response) {
202            Ok(document) => document,
203            Err(reason) => {
204                return Ok(self.visibility_inconclusive_report(
205                    audit,
206                    ApiVisibilityLeg::Control,
207                    reason,
208                    started_at,
209                ));
210            },
211        };
212        let comparator = ApiVisibilityComparator::default();
213        let control_view = match comparator.capture_profiled_view(
214            &request.profile,
215            request.control.context_id.as_str(),
216            request.resource_scope.as_str(),
217            ApiSurfaceKind::JsonHttp,
218            control_response.status(),
219            &control_document,
220        ) {
221            Ok(view) => view,
222            Err(_) => {
223                return Ok(self.visibility_inconclusive_report(
224                    audit,
225                    ApiVisibilityLeg::Control,
226                    ApiVisibilityInconclusiveReason::CanonicalizationRejected,
227                    started_at,
228                ));
229            },
230        };
231        drop(control_document);
232        drop(control_response);
233
234        if self.authority.cancellation().is_cancelled() {
235            return Ok(self.visibility_cancelled_report(
236                audit,
237                Some(ApiVisibilityLeg::Candidate),
238                started_at,
239            ));
240        }
241        if let Some(limit) = self.wall_limit_if_reached(started_at) {
242            return Ok(self.visibility_limit_report(
243                audit,
244                Some(ApiVisibilityLeg::Candidate),
245                limit,
246                started_at,
247            ));
248        }
249        let candidate_limits = match self.reserve_execution(
250            CANDIDATE_ACTION_ID,
251            DecisionExecutionStage::Active,
252            DecisionExecutionClass::TransportBound,
253            started_at,
254        ) {
255            Ok(limits) => limits,
256            Err(limit) => {
257                return Ok(self.visibility_limit_report(
258                    audit,
259                    Some(ApiVisibilityLeg::Candidate),
260                    limit,
261                    started_at,
262                ));
263            },
264        };
265        let candidate_result = await_execution(
266            self.authority.cancellation(),
267            deadline,
268            candidate_requests.collect_for_runtime(
269                CANDIDATE_ACTION_ID,
270                DecisionExecutionStage::Active,
271                None,
272                candidate_limits,
273                &request.candidate.probe,
274            ),
275        )
276        .await;
277        let candidate_response = match candidate_result {
278            RuntimeExecution::Completed(Ok(response)) => response,
279            RuntimeExecution::Completed(Err(error)) => {
280                return Ok(self.visibility_collection_report(
281                    audit,
282                    ApiVisibilityLeg::Candidate,
283                    error,
284                    started_at,
285                ));
286            },
287            RuntimeExecution::Cancelled => {
288                return Ok(self.visibility_cancelled_report(
289                    audit,
290                    Some(ApiVisibilityLeg::Candidate),
291                    started_at,
292                ));
293            },
294            RuntimeExecution::WallTimeExceeded => {
295                let limit = self.wall_limit(started_at);
296                return Ok(self.visibility_limit_report(
297                    audit,
298                    Some(ApiVisibilityLeg::Candidate),
299                    limit,
300                    started_at,
301                ));
302            },
303        };
304        audit.candidate = Some(leg_receipt(
305            ApiVisibilityLeg::Candidate,
306            &candidate_response,
307            &request.request_template_sha256,
308        ));
309        self.refresh_elapsed(started_at);
310        if let Some(limit) = self.response_limit_if_exceeded(CANDIDATE_ACTION_ID) {
311            return Ok(self.visibility_limit_report(
312                audit,
313                Some(ApiVisibilityLeg::Candidate),
314                limit,
315                started_at,
316            ));
317        }
318        if self.authority.cancellation().is_cancelled() {
319            return Ok(self.visibility_cancelled_report(
320                audit,
321                Some(ApiVisibilityLeg::Candidate),
322                started_at,
323            ));
324        }
325        if let Some(limit) = self.wall_limit_if_reached(started_at) {
326            return Ok(self.visibility_limit_report(
327                audit,
328                Some(ApiVisibilityLeg::Candidate),
329                limit,
330                started_at,
331            ));
332        }
333        let candidate_document = match response_document(&candidate_response) {
334            Ok(document) => document,
335            Err(reason) => {
336                return Ok(self.visibility_inconclusive_report(
337                    audit,
338                    ApiVisibilityLeg::Candidate,
339                    reason,
340                    started_at,
341                ));
342            },
343        };
344        let candidate_view = match comparator.capture_profiled_view(
345            &request.profile,
346            request.candidate.context_id.as_str(),
347            request.resource_scope.as_str(),
348            ApiSurfaceKind::JsonHttp,
349            candidate_response.status(),
350            &candidate_document,
351        ) {
352            Ok(view) => view,
353            Err(_) => {
354                return Ok(self.visibility_inconclusive_report(
355                    audit,
356                    ApiVisibilityLeg::Candidate,
357                    ApiVisibilityInconclusiveReason::CanonicalizationRejected,
358                    started_at,
359                ));
360            },
361        };
362        drop(candidate_document);
363        drop(candidate_response);
364
365        if self.authority.cancellation().is_cancelled() {
366            return Ok(self.visibility_cancelled_report(
367                audit,
368                Some(ApiVisibilityLeg::Candidate),
369                started_at,
370            ));
371        }
372        if let Some(limit) = self.wall_limit_if_reached(started_at) {
373            return Ok(self.visibility_limit_report(
374                audit,
375                Some(ApiVisibilityLeg::Candidate),
376                limit,
377                started_at,
378            ));
379        }
380
381        let comparison = match comparator.compare_profiled(
382            &request.profile,
383            request.comparison_id.into_string(),
384            ApiVisibilityPairKind::AuthorizationContext,
385            request.dimension,
386            &control_view,
387            &candidate_view,
388            request.observed_at_ms,
389        ) {
390            Ok(comparison) => comparison,
391            Err(source) => {
392                return Err(RuntimeApiVisibilityExecutionError::Comparison {
393                    audit: Box::new(self.finish_visibility_audit(audit, started_at)),
394                    source,
395                });
396            },
397        };
398        if self.authority.cancellation().is_cancelled() {
399            return Ok(self.visibility_cancelled_after_comparison_report(
400                audit, comparison, None, None, started_at,
401            ));
402        }
403        if let Some(limit) = self.wall_limit_if_reached(started_at) {
404            return Ok(self.visibility_limit_after_comparison_report(
405                audit, comparison, None, None, limit, started_at,
406            ));
407        }
408
409        let observation = match comparison
410            .to_observation(OBSERVATION_COMPONENT, evidence_reliability)
411            .map_err(ProfiledApiVisibilityError::from)
412        {
413            Ok(observation) => observation,
414            Err(source) => {
415                return Err(RuntimeApiVisibilityExecutionError::ObservationBuild {
416                    audit: Box::new(self.finish_visibility_audit(audit, started_at)),
417                    comparison: Box::new(comparison),
418                    source,
419                });
420            },
421        };
422        let observation = match ingest_api_visibility_observation(
423            observation,
424            &request.resource_scope,
425            self.authority.knowledge(),
426            self.decision_loop.rules(),
427        ) {
428            Ok(observation) => observation,
429            Err(source) => {
430                return Err(RuntimeApiVisibilityExecutionError::Observation {
431                    audit: Box::new(self.finish_visibility_audit(audit, started_at)),
432                    comparison: Box::new(comparison),
433                    source,
434                });
435            },
436        };
437        let Some(review) =
438            api_visibility_review_for_commit(self.authority.knowledge(), observation.commit())
439        else {
440            return Err(RuntimeApiVisibilityExecutionError::ReviewProjection {
441                audit: Box::new(self.finish_visibility_audit(audit, started_at)),
442                comparison: Box::new(comparison),
443                observation: Box::new(observation),
444            });
445        };
446        let disposition = match review.disposition() {
447            ApiVisibilityReviewDisposition::NoDifferenceObserved => {
448                ApiVisibilityDifferentialDisposition::NoDifferenceObserved
449            },
450            ApiVisibilityReviewDisposition::UnresolvedDifference => {
451                ApiVisibilityDifferentialDisposition::UnresolvedDifference
452            },
453            ApiVisibilityReviewDisposition::AwaitHumanReview => {
454                ApiVisibilityDifferentialDisposition::AwaitHumanReview
455            },
456        };
457        if self.authority.cancellation().is_cancelled() {
458            return Ok(self.visibility_cancelled_after_comparison_report(
459                audit,
460                comparison,
461                Some(observation),
462                Some(review),
463                started_at,
464            ));
465        }
466        if let Some(limit) = self.wall_limit_if_reached(started_at) {
467            return Ok(self.visibility_limit_after_comparison_report(
468                audit,
469                comparison,
470                Some(observation),
471                Some(review),
472                limit,
473                started_at,
474            ));
475        }
476        let audit = self.finish_visibility_audit(audit, started_at);
477        Ok(RuntimeApiVisibilityRunReport {
478            disposition,
479            stopped_leg: None,
480            inconclusive_reason: None,
481            audit,
482            comparison: Some(comparison),
483            observation: Some(observation),
484            review: Some(review),
485            limit_exceeded: None,
486        })
487    }
488
489    fn finish_visibility_audit(
490        &mut self,
491        mut audit: ApiVisibilityDifferentialAudit,
492        started_at: tokio::time::Instant,
493    ) -> ApiVisibilityDifferentialAudit {
494        self.refresh_elapsed(started_at);
495        audit.usage = self.usage.clone();
496        audit.transport = self.authority.request_accounting().dispatch_audit();
497        audit
498    }
499
500    fn visibility_cancelled_report(
501        &mut self,
502        audit: ApiVisibilityDifferentialAudit,
503        stopped_leg: Option<ApiVisibilityLeg>,
504        started_at: tokio::time::Instant,
505    ) -> RuntimeApiVisibilityRunReport {
506        RuntimeApiVisibilityRunReport {
507            disposition: ApiVisibilityDifferentialDisposition::CancelledByHost,
508            stopped_leg,
509            inconclusive_reason: None,
510            audit: self.finish_visibility_audit(audit, started_at),
511            comparison: None,
512            observation: None,
513            review: None,
514            limit_exceeded: None,
515        }
516    }
517
518    fn visibility_limit_report(
519        &mut self,
520        audit: ApiVisibilityDifferentialAudit,
521        stopped_leg: Option<ApiVisibilityLeg>,
522        limit: RuntimeLimitExceeded,
523        started_at: tokio::time::Instant,
524    ) -> RuntimeApiVisibilityRunReport {
525        RuntimeApiVisibilityRunReport {
526            disposition: ApiVisibilityDifferentialDisposition::RuntimeBudgetLimit,
527            stopped_leg,
528            inconclusive_reason: None,
529            audit: self.finish_visibility_audit(audit, started_at),
530            comparison: None,
531            observation: None,
532            review: None,
533            limit_exceeded: Some(limit),
534        }
535    }
536
537    fn visibility_cancelled_after_comparison_report(
538        &mut self,
539        audit: ApiVisibilityDifferentialAudit,
540        comparison: ProfiledApiVisibilityComparison,
541        observation: Option<ApiObservationReceipt>,
542        review: Option<ApiVisibilityReview>,
543        started_at: tokio::time::Instant,
544    ) -> RuntimeApiVisibilityRunReport {
545        RuntimeApiVisibilityRunReport {
546            disposition: ApiVisibilityDifferentialDisposition::CancelledByHost,
547            stopped_leg: None,
548            inconclusive_reason: None,
549            audit: self.finish_visibility_audit(audit, started_at),
550            comparison: Some(comparison),
551            observation,
552            review,
553            limit_exceeded: None,
554        }
555    }
556
557    fn visibility_limit_after_comparison_report(
558        &mut self,
559        audit: ApiVisibilityDifferentialAudit,
560        comparison: ProfiledApiVisibilityComparison,
561        observation: Option<ApiObservationReceipt>,
562        review: Option<ApiVisibilityReview>,
563        limit: RuntimeLimitExceeded,
564        started_at: tokio::time::Instant,
565    ) -> RuntimeApiVisibilityRunReport {
566        RuntimeApiVisibilityRunReport {
567            disposition: ApiVisibilityDifferentialDisposition::RuntimeBudgetLimit,
568            stopped_leg: None,
569            inconclusive_reason: None,
570            audit: self.finish_visibility_audit(audit, started_at),
571            comparison: Some(comparison),
572            observation,
573            review,
574            limit_exceeded: Some(limit),
575        }
576    }
577
578    fn visibility_inconclusive_report(
579        &mut self,
580        audit: ApiVisibilityDifferentialAudit,
581        stopped_leg: ApiVisibilityLeg,
582        reason: ApiVisibilityInconclusiveReason,
583        started_at: tokio::time::Instant,
584    ) -> RuntimeApiVisibilityRunReport {
585        RuntimeApiVisibilityRunReport {
586            disposition: ApiVisibilityDifferentialDisposition::Inconclusive,
587            stopped_leg: Some(stopped_leg),
588            inconclusive_reason: Some(reason),
589            audit: self.finish_visibility_audit(audit, started_at),
590            comparison: None,
591            observation: None,
592            review: None,
593            limit_exceeded: None,
594        }
595    }
596
597    fn visibility_collection_report(
598        &mut self,
599        audit: ApiVisibilityDifferentialAudit,
600        stopped_leg: ApiVisibilityLeg,
601        error: crate::http_evidence::HttpRequestBrokerError,
602        started_at: tokio::time::Instant,
603    ) -> RuntimeApiVisibilityRunReport {
604        let kind = error.failure_kind();
605        if let Some(limit) = error.into_runtime_limit() {
606            return self.visibility_limit_report(audit, Some(stopped_leg), limit, started_at);
607        }
608        self.visibility_inconclusive_report(
609            audit,
610            stopped_leg,
611            inconclusive_reason(kind),
612            started_at,
613        )
614    }
615}
616fn leg_receipt(
617    leg: ApiVisibilityLeg,
618    response: &crate::http_evidence::CollectedHttpResponse,
619    request_template_sha256: &str,
620) -> ApiVisibilityLegReceipt {
621    let mut hasher = Sha256::new();
622    hasher.update(RESPONSE_DIGEST_DOMAIN);
623    update_framed(&mut hasher, response.body());
624    ApiVisibilityLegReceipt {
625        leg,
626        status: response.status(),
627        retained_body_bytes: u64::try_from(response.body().len()).unwrap_or(u64::MAX),
628        body_truncated: response.body_truncated(),
629        json_compatible_media_type: response.has_json_compatible_media_type(),
630        request_template_sha256: request_template_sha256.to_owned(),
631        response_body_sha256: encode_digest(hasher.finalize().as_slice()),
632    }
633}
634
635fn response_document(
636    response: &crate::http_evidence::CollectedHttpResponse,
637) -> Result<serde_json::Value, ApiVisibilityInconclusiveReason> {
638    if response.status() == 429 {
639        return Err(ApiVisibilityInconclusiveReason::RateLimited);
640    }
641    if response.status() >= 500 {
642        return Err(ApiVisibilityInconclusiveReason::ServerError);
643    }
644    if response.body_truncated() {
645        return Err(ApiVisibilityInconclusiveReason::TruncatedResponse);
646    }
647    if !response.has_json_compatible_media_type() {
648        return Err(ApiVisibilityInconclusiveReason::NonJsonResponse);
649    }
650    serde_json::from_slice(response.body())
651        .map_err(|_| ApiVisibilityInconclusiveReason::MalformedJson)
652}
653
654fn inconclusive_reason(kind: DecisionExecutionFailureKind) -> ApiVisibilityInconclusiveReason {
655    match kind {
656        DecisionExecutionFailureKind::NotApplicable => {
657            ApiVisibilityInconclusiveReason::NotApplicable
658        },
659        DecisionExecutionFailureKind::BlockedByPolicy => {
660            ApiVisibilityInconclusiveReason::BlockedByPolicy
661        },
662        DecisionExecutionFailureKind::TransportFailure => {
663            ApiVisibilityInconclusiveReason::TransportFailure
664        },
665        DecisionExecutionFailureKind::RequestTimeout => {
666            ApiVisibilityInconclusiveReason::RequestTimeout
667        },
668        _ => ApiVisibilityInconclusiveReason::ExecutorFailure,
669    }
670}