1use std::{
8 collections::BTreeMap,
9 fmt,
10 sync::{Arc, Mutex},
11};
12
13use async_trait::async_trait;
14use serde::Serialize;
15use sha2::{Digest, Sha256};
16use termivar_core::{
17 ApiSurfaceKind, ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource,
18 EvidenceValue, HttpEvidencePredicate, KnowledgePredicate,
19};
20
21use super::{
22 assessment_defense::{project_assessment_defense_signal, AssessmentDefenseProjectionContext},
23 assessment_item::{
24 AssessmentCapabilityDescriptor, AssessmentItemProjectionError, AssessmentItemTarget,
25 AssessmentProjectionContext,
26 },
27};
28use crate::api_evidence::{ApiExactReplayComparison, ApiVisibilityEvidenceError};
29use crate::{http_evidence::HttpRequestBroker, http_evidence::HttpRequestBrokerError};
30use crate::{
31 rest_review::{RestDocumentedResponseClass, RestOperationSelection},
32 ApiVisibilityComparator, ApiVisibilityView, DecisionActionExecutor, DecisionActionOrigin,
33 DecisionExecutionRequest, DecisionExecutionStage, DecisionExecutorError,
34 DecisionExecutorRegistry, HttpProbe, HttpProbeMethod, KnowledgeBase, RuntimeLimitExceeded,
35 TransportDispatchOutcome,
36};
37
38pub const REST_REVIEW_ACTION_ID: &str = "web.review.rest.readonly-replay@1";
39pub const REST_REVIEW_CAPABILITY_ID: &str = "api.rest-readonly-surface-observed@1";
40pub const MAX_REST_REVIEW_RESOURCES: usize = 1;
41pub const MAX_REST_REVIEW_REQUESTS: usize = 2;
42pub const MAX_REST_REVIEW_ACTIVE_VERIFICATIONS: usize = 1;
43pub(super) const REST_REVIEW_ACTION_CYCLE_ALLOWANCE: u32 = 1;
44
45const REST_REVIEW_EXECUTOR_ID: &str = "http.rest-review";
46const REST_REVIEW_EVIDENCE_NAMESPACE: &str = "web.rest-review.transport";
47const REST_REVIEW_ACCEPT: &str = "application/json";
48const REST_REVIEW_BODY_DIGEST_DOMAIN: &[u8] = b"security.rest-review.body-receipt.v1\0";
49const REST_CLASSIFICATION_ALGORITHM: &str = "web.rest-review.value-free-classification";
50
51#[derive(Clone, Copy)]
52enum RestResponsePrivacy {
53 HistoricalDigests,
54 #[cfg(feature = "secret-exposure-review")]
55 ValueFree,
56}
57
58impl RestResponsePrivacy {
59 fn is_value_free(self) -> bool {
60 match self {
61 Self::HistoricalDigests => false,
62 #[cfg(feature = "secret-exposure-review")]
63 Self::ValueFree => true,
64 }
65 }
66}
67
68#[cfg(feature = "secret-exposure-review")]
69pub(super) fn rest_privacy_route_is_valid(
70 case: &crate::VerificationCase,
71 stage: DecisionExecutionStage,
72 executor: &str,
73) -> bool {
74 executor == REST_REVIEW_EXECUTOR_ID
75 && case.action_id() == REST_REVIEW_ACTION_ID
76 && !case.applies_hypothesis_transition()
77 && case.payload_strategy().is_none()
78 && matches!(
79 stage,
80 DecisionExecutionStage::Passive | DecisionExecutionStage::Active
81 )
82}
83
84const REST_REVIEW_CAPABILITY: AssessmentCapabilityDescriptor =
85 AssessmentCapabilityDescriptor::informational(
86 REST_REVIEW_CAPABILITY_ID,
87 "REST operation surface observed",
88 "API surface",
89 "Two anonymous exact-origin GET requests reproduced the same bounded JSON resource structure.",
90 900_000,
91 "api.rest-readonly-surface-review@1",
92 "Confirm that anonymously exposing this documented read-only operation matches deployment policy.",
93 );
94
95#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
97#[serde(rename_all = "snake_case")]
98pub enum RestRuntimeOutcome {
99 NotEligible,
100 SurfaceObserved,
101 ReplayMismatch,
102 CompleteNonJson,
103 Redirect,
104 AuthenticationRequired,
105 Forbidden,
106 NotFound,
107 RateLimited,
108 DefensiveInterference,
109 ServerError,
110 UnsupportedMedia,
111 Truncated,
112 Incomplete,
113 Cancelled,
114 BudgetExhausted,
115}
116
117#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum RestObservedMediaClass {
121 JsonCompatible,
122 Text,
123 Unsupported,
124 Unknown,
125}
126
127#[derive(Clone, Eq, PartialEq, Serialize)]
129pub struct WebAssessmentRestAudit {
130 outcome: RestRuntimeOutcome,
131 request_count: u8,
132 active_verification_count: u8,
133 eligible_operation_count: u32,
134 selected_operation_identity: Option<String>,
135 documented_response: Option<RestDocumentedResponseClass>,
136 observed_media: RestObservedMediaClass,
137 status_class: Option<u8>,
138 replay_stable: bool,
139 item_projected: bool,
140}
141
142impl WebAssessmentRestAudit {
143 pub const fn outcome(&self) -> RestRuntimeOutcome {
144 self.outcome
145 }
146
147 pub const fn request_count(&self) -> u8 {
148 self.request_count
149 }
150
151 pub const fn active_verification_count(&self) -> u8 {
152 self.active_verification_count
153 }
154
155 pub const fn eligible_operation_count(&self) -> u32 {
156 self.eligible_operation_count
157 }
158
159 pub fn selected_operation_identity(&self) -> Option<&str> {
160 self.selected_operation_identity.as_deref()
161 }
162
163 pub const fn documented_response(&self) -> Option<RestDocumentedResponseClass> {
164 self.documented_response
165 }
166
167 pub const fn observed_media(&self) -> RestObservedMediaClass {
168 self.observed_media
169 }
170
171 pub const fn status_class(&self) -> Option<u8> {
172 self.status_class
173 }
174
175 pub const fn replay_stable(&self) -> bool {
176 self.replay_stable
177 }
178
179 pub const fn item_projected(&self) -> bool {
180 self.item_projected
181 }
182}
183
184impl fmt::Debug for WebAssessmentRestAudit {
185 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
186 formatter
187 .debug_struct("WebAssessmentRestAudit")
188 .field("outcome", &self.outcome)
189 .field("request_count", &self.request_count)
190 .field("active_verification_count", &self.active_verification_count)
191 .field("eligible_operation_count", &self.eligible_operation_count)
192 .field(
193 "selected_operation_identity",
194 &self
195 .selected_operation_identity
196 .as_ref()
197 .map(|_| "<stable-digest>"),
198 )
199 .field("documented_response", &self.documented_response)
200 .field("observed_media", &self.observed_media)
201 .field("status_class", &self.status_class)
202 .field("replay_stable", &self.replay_stable)
203 .field("item_projected", &self.item_projected)
204 .finish()
205 }
206}
207
208#[derive(Clone, Default)]
211pub(super) struct StableRestSelectionSlot {
212 selection: Arc<Mutex<Option<RestOperationSelection>>>,
213}
214
215impl StableRestSelectionSlot {
216 pub(super) fn new() -> Self {
217 Self::default()
218 }
219
220 pub(super) fn commit(
221 &self,
222 selection: RestOperationSelection,
223 ) -> Result<(), RestRuntimeInvariantError> {
224 let mut slot = self
225 .selection
226 .lock()
227 .map_err(|_| RestRuntimeInvariantError::Catalog)?;
228 if slot.is_some() {
229 return Err(RestRuntimeInvariantError::Catalog);
230 }
231 *slot = Some(selection);
232 Ok(())
233 }
234
235 pub(super) fn selection(
236 &self,
237 ) -> Result<Option<RestOperationSelection>, RestRuntimeInvariantError> {
238 self.selection
239 .lock()
240 .map_err(|_| RestRuntimeInvariantError::Catalog)
241 .map(|selection| selection.clone())
242 }
243}
244
245impl fmt::Debug for StableRestSelectionSlot {
246 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
247 formatter.write_str("StableRestSelectionSlot(<redacted-single-assignment>)")
248 }
249}
250
251pub(super) struct RestReviewBinding {
253 executor: Arc<RestDecisionExecutor>,
254 subject: EntityId,
255}
256
257impl RestReviewBinding {
258 pub(super) fn new(
259 selection: StableRestSelectionSlot,
260 requests: HttpRequestBroker,
261 subject: EntityId,
262 value_free: bool,
263 ) -> Self {
264 let privacy = RestResponsePrivacy::HistoricalDigests;
265 #[cfg(feature = "secret-exposure-review")]
266 let privacy = if value_free {
267 RestResponsePrivacy::ValueFree
268 } else {
269 privacy
270 };
271 #[cfg(not(feature = "secret-exposure-review"))]
272 debug_assert!(!value_free);
273 let executor = Arc::new(RestDecisionExecutor {
274 requests,
275 selection,
276 subject: subject.clone(),
277 privacy,
278 state: Mutex::new(RestExecutionState::default()),
279 });
280 Self { executor, subject }
281 }
282
283 pub(super) fn install_into_parent_registry(
284 &self,
285 registry: &mut DecisionExecutorRegistry,
286 ) -> Result<(), RestRuntimeInvariantError> {
287 let before = registry.len();
288 let executor: Arc<dyn DecisionActionExecutor> = self.executor.clone();
289 registry
290 .register(executor)
291 .map_err(|_| RestRuntimeInvariantError::Catalog)?;
292 for stage in [
293 DecisionExecutionStage::Passive,
294 DecisionExecutionStage::Active,
295 ] {
296 registry
297 .route_action(stage, REST_REVIEW_ACTION_ID, REST_REVIEW_EXECUTOR_ID)
298 .map_err(|_| RestRuntimeInvariantError::Catalog)?;
299 }
300 if registry.len() != before + 1 {
301 return Err(RestRuntimeInvariantError::Catalog);
302 }
303 Ok(())
304 }
305
306 pub(super) fn finalize(
307 self,
308 knowledge: &KnowledgeBase,
309 standard: &super::StandardWebDecisionRunReport,
310 forced_outcome: Option<RestRuntimeOutcome>,
311 forced_runtime_limit: Option<RuntimeLimitExceeded>,
312 ) -> Result<RestRuntimeResult, RestRuntimeInvariantError> {
313 let transport = standard.transport();
314 if transport.omitted_receipt_count() != 0 {
315 return Err(RestRuntimeInvariantError::Catalog);
316 }
317 let selection = self.executor.selection.selection()?;
318 let receipts = transport
319 .receipts()
320 .iter()
321 .filter(|receipt| receipt.action_id() == REST_REVIEW_ACTION_ID)
322 .collect::<Vec<_>>();
323 if !rest_transport_prefix_is_valid(&receipts) {
324 return Err(RestRuntimeInvariantError::Catalog);
325 }
326 let state = self.executor.take_state()?;
327 #[cfg(feature = "secret-exposure-review")]
328 let committed = if self.executor.privacy.is_value_free() {
329 validate_value_free_rest_commits(
330 standard,
331 knowledge,
332 &self.subject,
333 selection.as_ref(),
334 &state,
335 )?
336 } else {
337 true
338 };
339 #[cfg(not(feature = "secret-exposure-review"))]
340 let committed = true;
341 if !captured_rest_prefix_reconciles(&state, &receipts) {
342 return Err(RestRuntimeInvariantError::Catalog);
343 }
344 let request_count = u8::try_from(receipts.len()).unwrap_or(u8::MAX);
345 let terminal = forced_outcome.or(if committed {
346 state.terminal
347 } else {
348 Some(RestRuntimeOutcome::Incomplete)
349 });
350 let runtime_limit = if forced_outcome.is_some() {
351 forced_runtime_limit
352 } else {
353 state.runtime_limit
354 };
355
356 if let Some(outcome) = terminal {
357 let audit = audit_for_privacy(
358 selection.as_ref(),
359 outcome,
360 request_count,
361 state.terminal_observation,
362 false,
363 self.executor.privacy,
364 );
365 if matches!(
366 outcome,
367 RestRuntimeOutcome::Truncated
368 | RestRuntimeOutcome::Incomplete
369 | RestRuntimeOutcome::BudgetExhausted
370 | RestRuntimeOutcome::Cancelled
371 ) {
372 return Ok(RestRuntimeResult::Stopped {
373 audit,
374 runtime_limit,
375 });
376 }
377 return Ok(RestRuntimeResult::Complete(CommittedRestReview {
378 subject: self.subject,
379 privacy: self.executor.privacy,
380 target_identity: selection
381 .as_ref()
382 .map(|selected| selected.target_identity().to_owned()),
383 outcome,
384 evidence_ids: state
385 .legs
386 .values()
387 .flat_map(|leg| leg.evidence_ids.iter().cloned())
388 .collect(),
389 audit,
390 }));
391 }
392
393 let Some(selection) = selection else {
394 return Ok(RestRuntimeResult::Complete(CommittedRestReview {
395 subject: self.subject,
396 privacy: self.executor.privacy,
397 target_identity: None,
398 outcome: RestRuntimeOutcome::NotEligible,
399 evidence_ids: Vec::new(),
400 audit: audit_for_privacy(
401 None,
402 RestRuntimeOutcome::NotEligible,
403 request_count,
404 None,
405 false,
406 self.executor.privacy,
407 ),
408 }));
409 };
410 let (Some(candidate), Some(replay)) = (
411 state.legs.get(&DecisionExecutionStage::Passive),
412 state.legs.get(&DecisionExecutionStage::Active),
413 ) else {
414 return Ok(RestRuntimeResult::Stopped {
415 audit: audit_for_privacy(
416 Some(&selection),
417 RestRuntimeOutcome::Incomplete,
418 request_count,
419 None,
420 false,
421 self.executor.privacy,
422 ),
423 runtime_limit: None,
424 });
425 };
426 if receipts.len() != MAX_REST_REVIEW_REQUESTS
427 || receipts
428 .iter()
429 .zip([candidate, replay])
430 .any(|(receipt, leg)| {
431 receipt.outcome() != TransportDispatchOutcome::Completed
432 || receipt.response_bytes() != leg.response_bytes
433 })
434 {
435 return Ok(RestRuntimeResult::Stopped {
436 audit: audit_for_privacy(
437 Some(&selection),
438 RestRuntimeOutcome::Incomplete,
439 request_count,
440 None,
441 false,
442 self.executor.privacy,
443 ),
444 runtime_limit: None,
445 });
446 }
447
448 let comparison = ApiVisibilityComparator::default()
449 .compare_exact_replay(&candidate.view, &replay.view)
450 .map_err(|_| RestRuntimeInvariantError::Catalog)?;
451 let projected = comparison.all_equivalent();
452 let outcome = if projected {
453 RestRuntimeOutcome::SurfaceObserved
454 } else {
455 RestRuntimeOutcome::ReplayMismatch
456 };
457 let evidence_ids = state
458 .legs
459 .values()
460 .flat_map(|leg| leg.evidence_ids.iter().cloned())
461 .collect::<Vec<_>>();
462 if evidence_ids.is_empty()
463 || evidence_ids
464 .iter()
465 .any(|evidence_id| knowledge.evidence(evidence_id).is_none())
466 {
467 return Err(RestRuntimeInvariantError::Catalog);
468 }
469 let audit = audit_for_privacy(
470 Some(&selection),
471 outcome,
472 request_count,
473 Some(replay.audit_observation()),
474 projected,
475 self.executor.privacy,
476 );
477 Ok(RestRuntimeResult::Complete(CommittedRestReview {
478 subject: self.subject,
479 privacy: self.executor.privacy,
480 target_identity: Some(selection.target_identity().to_owned()),
481 outcome,
482 evidence_ids,
483 audit,
484 }))
485 }
486}
487
488#[cfg(feature = "secret-exposure-review")]
491fn validate_value_free_rest_commits(
492 standard: &super::StandardWebDecisionRunReport,
493 knowledge: &KnowledgeBase,
494 subject: &EntityId,
495 selection: Option<&RestOperationSelection>,
496 state: &RestExecutionState,
497) -> Result<bool, RestRuntimeInvariantError> {
498 use crate::{KnowledgeWrite, StandardWebDecisionRuntimeTurn};
499 use std::collections::BTreeSet;
500 let receipts = standard
501 .turns()
502 .iter()
503 .filter_map(|turn| match turn {
504 StandardWebDecisionRuntimeTurn::Outcome { evidence, .. } => Some(evidence.as_ref()),
505 StandardWebDecisionRuntimeTurn::Planning(_) => None,
506 })
507 .chain(standard.unverified_evidence())
508 .filter(|receipt| {
509 receipt.case().action_id() == REST_REVIEW_ACTION_ID
510 || receipt.executor_id() == REST_REVIEW_EXECUTOR_ID
511 })
512 .collect::<Vec<_>>();
513 if receipts.len() > MAX_REST_REVIEW_REQUESTS {
514 return Err(RestRuntimeInvariantError::Catalog);
515 }
516 let mut committed = BTreeSet::new();
517 let mut terminal_committed = false;
518 for (index, receipt) in receipts.into_iter().enumerate() {
519 let expected_stage = if index == 0 {
520 DecisionExecutionStage::Passive
521 } else {
522 DecisionExecutionStage::Active
523 };
524 if !rest_privacy_route_is_valid(receipt.case(), receipt.stage(), receipt.executor_id())
525 || receipt.case().subject() != subject
526 || receipt.stage() != expected_stage
527 || receipt.evidence().len() != receipt.writes().len()
528 {
529 return Err(RestRuntimeInvariantError::Catalog);
530 }
531 let mut ids = BTreeSet::new();
532 for (item, write) in receipt.write_set() {
533 if !matches!(write, KnowledgeWrite::Inserted | KnowledgeWrite::Unchanged)
534 || item.subject() != subject
535 || item.source().component() != REST_REVIEW_EXECUTOR_ID
536 || item.source().correlation_id() != Some(receipt.case().id())
537 || item.reliability() != ConfidenceScore::MAX
538 || !ids.insert(item.id())
539 || knowledge.evidence(item.id()).as_ref() != Some(item)
540 || item.predicate() == &HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge()
541 {
542 return Err(RestRuntimeInvariantError::Catalog);
543 }
544 }
545 let phase = rest_receipt_fact(
546 receipt,
547 &crate::web_actions::rest_review_phase_terminal_predicate(),
548 )?;
549 if phase.kind() != &EvidenceKind::Custom("rest-review".to_owned())
550 || !phase.origin().is_direct()
551 || phase.source().method() != "phase-terminal"
552 {
553 return Err(RestRuntimeInvariantError::Catalog);
554 }
555 let response_received = receipt.evidence().iter().any(|item| {
556 item.predicate() == &HttpEvidencePredicate::REQUEST_METHOD.into_knowledge()
557 });
558 if !response_received {
559 let bytes = rest_receipt_fact(
560 receipt,
561 &HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
562 )?;
563 if state.terminal.is_none()
564 || state.legs.contains_key(&receipt.stage())
565 || receipt.evidence().len() != 2
566 || bytes.kind() != &EvidenceKind::Custom("rest-review".to_owned())
567 || !bytes.origin().is_direct()
568 || bytes.source().method() != "response-body-size"
569 || bytes.value() != &EvidenceValue::Unsigned(0)
570 || phase.value() != &EvidenceValue::Boolean(true)
571 {
572 return Err(RestRuntimeInvariantError::Catalog);
573 }
574 terminal_committed = true;
575 continue;
576 }
577 let selected = selection.ok_or(RestRuntimeInvariantError::Catalog)?;
578 let parents = super::secret_exposure::rest_response_privacy_parent_ids(
579 receipt.evidence(),
580 receipt.case(),
581 receipt.stage(),
582 receipt.executor_id(),
583 )
584 .ok_or(RestRuntimeInvariantError::Catalog)?;
585 let lineage_predicate = KnowledgePredicate::new(
586 super::secret_exposure::SECRET_EXPOSURE_NAMESPACE,
587 super::secret_exposure::ACTIVE_REVIEW_BODY_LINEAGE,
588 )
589 .map_err(|_| RestRuntimeInvariantError::Catalog)?;
590 let lineage = rest_receipt_fact(receipt, &lineage_predicate)?;
591 if !super::secret_exposure::is_active_review_body_privacy_lineage(
592 lineage,
593 subject,
594 receipt.case().id(),
595 REST_REVIEW_EXECUTOR_ID,
596 &parents,
597 ) {
598 return Err(RestRuntimeInvariantError::Catalog);
599 }
600 for predicate in [
601 HttpEvidencePredicate::REQUEST_URL,
602 HttpEvidencePredicate::RESPONSE_FINAL_URL,
603 ] {
604 if rest_receipt_fact(receipt, &predicate.into_knowledge())?.value()
605 != &EvidenceValue::Text(selected.execution_url().to_string())
606 {
607 return Err(RestRuntimeInvariantError::Catalog);
608 }
609 }
610 let fixed_predicates = [
611 HttpEvidencePredicate::REQUEST_METHOD,
612 HttpEvidencePredicate::REQUEST_URL,
613 HttpEvidencePredicate::RESPONSE_STATUS,
614 HttpEvidencePredicate::RESPONSE_FINAL_URL,
615 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
616 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
617 HttpEvidencePredicate::RATE_LIMIT_DETECTED,
618 HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
619 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE,
620 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE,
621 ]
622 .map(|predicate| predicate.into_knowledge());
623 for (predicate, kind, method) in [
624 (
625 HttpEvidencePredicate::RATE_LIMIT_DETECTED,
626 EvidenceKind::RateLimit,
627 "rate-limit-status",
628 ),
629 (
630 HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
631 EvidenceKind::RateLimit,
632 "rate-limit-headers",
633 ),
634 ] {
635 let item = rest_receipt_fact(receipt, &predicate.into_knowledge())?;
636 if item.kind() != &kind
637 || !item.origin().is_direct()
638 || item.source().method() != method
639 || !matches!(item.value(), EvidenceValue::Boolean(_))
640 {
641 return Err(RestRuntimeInvariantError::Catalog);
642 }
643 }
644 let media_predicate = HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge();
645 let compatibility_predicate =
646 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge();
647 let media = receipt
648 .evidence()
649 .iter()
650 .find(|item| item.predicate() == &media_predicate);
651 let mut compatibility = receipt
652 .evidence()
653 .iter()
654 .filter(|item| item.predicate() == &compatibility_predicate);
655 match (media, compatibility.next()) {
656 (Some(media), Some(compatible)) => {
657 let EvidenceValue::Text(media_type) = media.value() else {
658 return Err(RestRuntimeInvariantError::Catalog);
659 };
660 if compatibility.next().is_some()
661 || compatible.kind() != &EvidenceKind::Http
662 || !compatible.origin().is_direct()
663 || compatible.source().method() != "response-media-type-json-compatibility"
664 || compatible.value()
665 != &EvidenceValue::Boolean(
666 crate::http_evidence::json_compatible_media_type(media_type),
667 )
668 {
669 return Err(RestRuntimeInvariantError::Catalog);
670 }
671 },
672 (None, None) => {},
673 _ => return Err(RestRuntimeInvariantError::Catalog),
674 }
675 if receipt.evidence().iter().any(|item| {
676 let rest_fact = item.predicate().namespace() == REST_REVIEW_EVIDENCE_NAMESPACE
677 && matches!(
678 item.predicate().name(),
679 "view"
680 | "phase-terminal"
681 | "status-equivalent"
682 | "fields-equivalent"
683 | "resources-equivalent"
684 );
685 let known_fact = fixed_predicates.contains(item.predicate())
686 || rest_fact
687 || item.predicate() == &lineage_predicate
688 || item.predicate()
689 == &crate::web_actions::native_web_review_response_marker_predicate()
690 || item.predicate().namespace()
691 == super::assessment_defense::ASSESSMENT_DEFENSE_NAMESPACE;
692 !known_fact
693 || (item.predicate().namespace()
694 == super::secret_exposure::SECRET_EXPOSURE_NAMESPACE
695 && item.predicate() != &lineage_predicate)
696 || (item.predicate().namespace() == REST_REVIEW_EVIDENCE_NAMESPACE
697 && !matches!(
698 item.predicate().name(),
699 "view"
700 | "phase-terminal"
701 | "status-equivalent"
702 | "fields-equivalent"
703 | "resources-equivalent"
704 ))
705 }) {
706 return Err(RestRuntimeInvariantError::Catalog);
707 }
708 let view_predicate = KnowledgePredicate::new(REST_REVIEW_EVIDENCE_NAMESPACE, "view")
709 .map_err(|_| RestRuntimeInvariantError::Catalog)?;
710 let completion_predicate =
711 crate::web_actions::native_web_review_response_marker_predicate();
712 let completion = receipt
713 .evidence()
714 .iter()
715 .filter(|item| item.predicate() == &completion_predicate)
716 .collect::<Vec<_>>();
717 let classifications = receipt
718 .evidence()
719 .iter()
720 .filter(|item| {
721 item.predicate().namespace() == REST_REVIEW_EVIDENCE_NAMESPACE
722 && item.predicate().name() != "phase-terminal"
723 })
724 .collect::<Vec<_>>();
725 let wire = standard
726 .transport()
727 .receipts()
728 .iter()
729 .filter(|item| item.action_id() == REST_REVIEW_ACTION_ID)
730 .nth(index)
731 .ok_or(RestRuntimeInvariantError::Catalog)?;
732 let bytes = rest_receipt_fact(
733 receipt,
734 &HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
735 )?;
736 let Some(leg) = state.legs.get(&receipt.stage()) else {
737 if state.terminal.is_none()
738 || !classifications.is_empty()
739 || !completion.is_empty()
740 || phase.value() != &EvidenceValue::Boolean(true)
741 || !matches!(
742 wire.outcome(),
743 TransportDispatchOutcome::Completed
744 | TransportDispatchOutcome::ResponseBudgetReached
745 )
746 || !matches!(bytes.value(), EvidenceValue::Unsigned(value) if *value <= wire.response_bytes())
747 {
748 return Err(RestRuntimeInvariantError::Catalog);
749 }
750 terminal_committed = true;
751 continue;
752 };
753 let mut expected_parents = BTreeSet::new();
754 for item in receipt
755 .evidence()
756 .iter()
757 .filter(|item| fixed_predicates.contains(item.predicate()) || item.id() == phase.id())
758 {
759 if !item.origin().is_direct() {
760 return Err(RestRuntimeInvariantError::Catalog);
761 }
762 expected_parents.insert(item.id().clone());
763 }
764 expected_parents.insert(lineage.id().clone());
765 let expected_parents = expected_parents.into_iter().collect::<Vec<_>>();
766 let view = rest_receipt_fact(receipt, &view_predicate)?;
767 let expected_view = EvidenceValue::TextList(vec![
768 format!("leg={}", rest_leg_identity(receipt.stage())),
769 format!("status={}", leg.view.status()),
770 "method=GET".to_owned(),
771 format!("media={}", observed_media_name(leg.media)),
772 "complete=true".to_owned(),
773 "truncated=false".to_owned(),
774 "rate_limited=false".to_owned(),
775 "challenged=false".to_owned(),
776 ]);
777 if completion.len() != 1
778 || !completion[0].origin().is_direct()
779 || completion[0].source().method() != "complete"
780 || completion[0].value() != &EvidenceValue::Boolean(true)
781 || phase.value() != &EvidenceValue::Boolean(false)
782 || bytes.value() != &EvidenceValue::Unsigned(leg.response_bytes)
783 || wire.outcome() != TransportDispatchOutcome::Completed
784 || wire.response_bytes() != leg.response_bytes
785 || rest_receipt_fact(
786 receipt,
787 &HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into_knowledge(),
788 )?
789 .value()
790 != &EvidenceValue::Boolean(false)
791 || rest_receipt_fact(
792 receipt,
793 &HttpEvidencePredicate::RATE_LIMIT_DETECTED.into_knowledge(),
794 )?
795 .value()
796 != &EvidenceValue::Boolean(false)
797 || rest_receipt_fact(
798 receipt,
799 &HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge(),
800 )?
801 .value()
802 != &EvidenceValue::Unsigned(u64::from(leg.view.status()))
803 || rest_receipt_fact(
804 receipt,
805 &HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
806 )?
807 .value()
808 != &EvidenceValue::Boolean(true)
809 || view.value() != &expected_view
810 || view.source().method() != rest_leg_identity(receipt.stage())
811 || !valid_rest_classification(view, &expected_parents)
812 {
813 return Err(RestRuntimeInvariantError::Catalog);
814 }
815 let mut expected_ids = vec![view.id().clone()];
816 if receipt.stage() == DecisionExecutionStage::Active {
817 let candidate = state
818 .legs
819 .get(&DecisionExecutionStage::Passive)
820 .ok_or(RestRuntimeInvariantError::Catalog)?;
821 if !committed.contains(&DecisionExecutionStage::Passive) {
822 return Err(RestRuntimeInvariantError::Catalog);
823 }
824 let comparison = ApiVisibilityComparator::default()
825 .compare_exact_replay(&candidate.view, &leg.view)
826 .map_err(|_| RestRuntimeInvariantError::Catalog)?;
827 for (name, expected) in [
828 ("status-equivalent", comparison.status()),
829 ("fields-equivalent", comparison.fields()),
830 ("resources-equivalent", comparison.resources()),
831 ] {
832 let predicate = KnowledgePredicate::new(REST_REVIEW_EVIDENCE_NAMESPACE, name)
833 .map_err(|_| RestRuntimeInvariantError::Catalog)?;
834 let item = rest_receipt_fact(receipt, &predicate)?;
835 if item.source().method() != name
836 || item.value() != &EvidenceValue::Boolean(expected)
837 || !valid_rest_classification(item, &expected_parents)
838 {
839 return Err(RestRuntimeInvariantError::Catalog);
840 }
841 expected_ids.push(item.id().clone());
842 }
843 }
844 if classifications.len() != expected_ids.len() || leg.evidence_ids != expected_ids {
845 return Err(RestRuntimeInvariantError::Catalog);
846 }
847 committed.insert(receipt.stage());
848 }
849 Ok(state.legs.keys().all(|stage| committed.contains(stage))
850 && (state.terminal.is_none() || terminal_committed))
851}
852
853#[cfg(feature = "secret-exposure-review")]
854fn valid_rest_classification(item: &Evidence, parents: &[EvidenceId]) -> bool {
855 item.kind() == &EvidenceKind::Custom("rest-review".to_owned())
856 && item.origin().derivation().is_some_and(|derivation| {
857 derivation.algorithm().name() == REST_CLASSIFICATION_ALGORITHM
858 && derivation.algorithm().version() == 1
859 && derivation.parents() == parents
860 })
861}
862
863#[cfg(feature = "secret-exposure-review")]
864fn rest_receipt_fact<'a>(
865 receipt: &'a crate::DecisionEvidenceReceipt,
866 predicate: &KnowledgePredicate,
867) -> Result<&'a Evidence, RestRuntimeInvariantError> {
868 let mut matches = receipt
869 .evidence()
870 .iter()
871 .filter(|item| item.predicate() == predicate);
872 let item = matches.next().ok_or(RestRuntimeInvariantError::Catalog)?;
873 if matches.next().is_some() {
874 return Err(RestRuntimeInvariantError::Catalog);
875 }
876 Ok(item)
877}
878
879fn rest_transport_prefix_is_valid(receipts: &[&crate::TransportDispatchReceipt]) -> bool {
880 receipts.len() <= MAX_REST_REVIEW_REQUESTS
881 && receipts.iter().enumerate().all(|(index, receipt)| {
882 let expected_stage = if index == 0 {
883 DecisionExecutionStage::Passive
884 } else {
885 DecisionExecutionStage::Active
886 };
887 let expected_origin = (expected_stage == DecisionExecutionStage::Passive)
888 .then_some(DecisionActionOrigin::Planned);
889 receipt.stage() == expected_stage
890 && receipt.origin() == expected_origin
891 && receipt.request_body_bytes() == 0
892 })
893}
894
895fn captured_rest_prefix_reconciles(
896 state: &RestExecutionState,
897 receipts: &[&crate::TransportDispatchReceipt],
898) -> bool {
899 state.legs.iter().all(|(stage, leg)| {
900 let index = match stage {
901 DecisionExecutionStage::Passive => 0,
902 DecisionExecutionStage::Active => 1,
903 };
904 receipts.get(index).is_some_and(|receipt| {
905 receipt.outcome() == TransportDispatchOutcome::Completed
906 && receipt.response_bytes() == leg.response_bytes
907 })
908 })
909}
910
911#[derive(Debug, thiserror::Error)]
912pub(super) enum RestRuntimeInvariantError {
913 #[error("REST review catalog invariant failed")]
914 Catalog,
915}
916
917pub(super) enum RestRuntimeResult {
918 Complete(CommittedRestReview),
919 Stopped {
920 audit: WebAssessmentRestAudit,
921 runtime_limit: Option<RuntimeLimitExceeded>,
922 },
923}
924
925pub(super) struct CommittedRestReview {
926 subject: EntityId,
927 privacy: RestResponsePrivacy,
928 target_identity: Option<String>,
929 outcome: RestRuntimeOutcome,
930 evidence_ids: Vec<EvidenceId>,
931 audit: WebAssessmentRestAudit,
932}
933
934impl CommittedRestReview {
935 pub(super) const fn audit(&self) -> &WebAssessmentRestAudit {
936 &self.audit
937 }
938}
939
940struct RestLeg {
941 view: ApiVisibilityView,
942 response_bytes: u64,
943 media: RestObservedMediaClass,
944 evidence_ids: Vec<EvidenceId>,
945}
946
947impl RestLeg {
948 fn audit_observation(&self) -> RestAuditObservation {
949 RestAuditObservation {
950 media: self.media,
951 status_class: Some((self.view.status() / 100) as u8),
952 }
953 }
954}
955
956#[derive(Clone, Copy)]
957struct RestAuditObservation {
958 media: RestObservedMediaClass,
959 status_class: Option<u8>,
960}
961
962impl Default for RestAuditObservation {
963 fn default() -> Self {
964 Self {
965 media: RestObservedMediaClass::Unknown,
966 status_class: None,
967 }
968 }
969}
970
971impl RestAuditObservation {
972 fn new(status: u16, media: RestObservedMediaClass) -> Self {
973 Self {
974 media,
975 status_class: Some((status / 100) as u8),
976 }
977 }
978}
979
980#[derive(Default)]
981struct RestExecutionState {
982 legs: BTreeMap<DecisionExecutionStage, RestLeg>,
983 terminal: Option<RestRuntimeOutcome>,
984 terminal_observation: Option<RestAuditObservation>,
985 runtime_limit: Option<RuntimeLimitExceeded>,
986}
987
988struct RestDecisionExecutor {
989 requests: HttpRequestBroker,
990 selection: StableRestSelectionSlot,
991 subject: EntityId,
992 privacy: RestResponsePrivacy,
993 state: Mutex<RestExecutionState>,
994}
995
996impl RestDecisionExecutor {
997 fn take_state(&self) -> Result<RestExecutionState, RestRuntimeInvariantError> {
998 Ok(std::mem::take(
999 &mut *self
1000 .state
1001 .lock()
1002 .map_err(|_| RestRuntimeInvariantError::Catalog)?,
1003 ))
1004 }
1005
1006 fn stop(
1007 &self,
1008 outcome: RestRuntimeOutcome,
1009 runtime_limit: Option<RuntimeLimitExceeded>,
1010 observation: Option<RestAuditObservation>,
1011 ) -> Result<(), DecisionExecutorError> {
1012 let mut state = self
1013 .state
1014 .lock()
1015 .map_err(|_| DecisionExecutorError::new("REST review state is unavailable"))?;
1016 if state.terminal.is_some() || state.runtime_limit.is_some() {
1017 return Err(DecisionExecutorError::new(
1018 "REST review terminal state is duplicated",
1019 ));
1020 }
1021 state.terminal = Some(outcome);
1022 state.terminal_observation = observation;
1023 state.runtime_limit = runtime_limit;
1024 Ok(())
1025 }
1026}
1027
1028#[async_trait]
1029impl DecisionActionExecutor for RestDecisionExecutor {
1030 fn id(&self) -> &str {
1031 REST_REVIEW_EXECUTOR_ID
1032 }
1033
1034 async fn execute(
1035 &self,
1036 request: &DecisionExecutionRequest,
1037 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1038 if request.case().action_id() != REST_REVIEW_ACTION_ID
1039 || request.case().subject() != &self.subject
1040 || request.case().payload_strategy().is_some()
1041 || request.case().applies_hypothesis_transition()
1042 || request.delay_ms().is_some()
1043 || !matches!(
1044 request.stage(),
1045 DecisionExecutionStage::Passive | DecisionExecutionStage::Active
1046 )
1047 {
1048 return Err(DecisionExecutorError::new(
1049 "REST executor route contract failed",
1050 ));
1051 }
1052 {
1053 let state = self
1054 .state
1055 .lock()
1056 .map_err(|_| DecisionExecutorError::new("REST review state is unavailable"))?;
1057 let phase_ready = match request.stage() {
1058 DecisionExecutionStage::Passive => {
1059 state.legs.is_empty() && state.terminal.is_none()
1060 },
1061 DecisionExecutionStage::Active => {
1062 state.legs.contains_key(&DecisionExecutionStage::Passive)
1063 && !state.legs.contains_key(&DecisionExecutionStage::Active)
1064 && state.terminal.is_none()
1065 },
1066 };
1067 if !phase_ready {
1068 return Err(DecisionExecutorError::new(
1069 "REST executor phase contract failed",
1070 ));
1071 }
1072 }
1073
1074 let Some(selection) = self
1075 .selection
1076 .selection()
1077 .map_err(|_| DecisionExecutorError::new("REST selection is unavailable"))?
1078 else {
1079 self.stop(RestRuntimeOutcome::NotEligible, None, None)?;
1080 return phase_terminal_evidence(request);
1081 };
1082 let probe = HttpProbe::new(selection.execution_url().clone(), HttpProbeMethod::Get)
1083 .and_then(|probe| probe.with_header("accept", REST_REVIEW_ACCEPT))
1084 .map_err(|_| DecisionExecutorError::new("REST request construction failed"))?;
1085 let response = match self
1086 .requests
1087 .collect_for_runtime(
1088 REST_REVIEW_ACTION_ID,
1089 request.stage(),
1090 request.origin(),
1091 request.limits(),
1092 &probe,
1093 )
1094 .await
1095 {
1096 Ok(response) => response,
1097 Err(HttpRequestBrokerError::RuntimeLimit(limit)) => {
1098 self.stop(RestRuntimeOutcome::BudgetExhausted, Some(limit), None)?;
1099 return phase_terminal_evidence(request);
1100 },
1101 Err(HttpRequestBrokerError::Http(_)) => {
1102 self.stop(RestRuntimeOutcome::Incomplete, None, None)?;
1103 return phase_terminal_evidence(request);
1104 },
1105 };
1106
1107 let status = response.status();
1108 let signal = response.openapi_defense_signal();
1109 let media = observed_media_class(response.normalized_media_type().as_deref());
1110 let terminal =
1111 if response.final_url() != selection.execution_url() || (300..400).contains(&status) {
1112 Some(RestRuntimeOutcome::Redirect)
1113 } else if response.body_truncated() {
1114 Some(RestRuntimeOutcome::Truncated)
1115 } else if !response.body_complete() {
1116 Some(RestRuntimeOutcome::Incomplete)
1117 } else if signal.state().is_rate_limited() || status == 429 {
1118 Some(RestRuntimeOutcome::RateLimited)
1119 } else if signal.state().is_challenged() {
1120 Some(RestRuntimeOutcome::DefensiveInterference)
1121 } else {
1122 status_outcome(status)
1123 };
1124 if let Some(outcome) = terminal {
1125 self.stop(
1126 outcome,
1127 None,
1128 Some(RestAuditObservation::new(status, media)),
1129 )?;
1130 return transport_evidence(request, &response, None, None, true, self.privacy);
1131 }
1132 if !response.has_json_compatible_media_type() {
1133 let outcome = match media {
1134 RestObservedMediaClass::Text => RestRuntimeOutcome::CompleteNonJson,
1135 RestObservedMediaClass::Unsupported | RestObservedMediaClass::Unknown => {
1136 RestRuntimeOutcome::UnsupportedMedia
1137 },
1138 RestObservedMediaClass::JsonCompatible => unreachable!("media was checked above"),
1139 };
1140 self.stop(
1141 outcome,
1142 None,
1143 Some(RestAuditObservation::new(status, media)),
1144 )?;
1145 return transport_evidence(request, &response, None, None, true, self.privacy);
1146 }
1147 let json: serde_json::Value = match serde_json::from_slice(response.body()) {
1148 Ok(json) => json,
1149 Err(_) => {
1150 self.stop(
1151 RestRuntimeOutcome::Incomplete,
1152 None,
1153 Some(RestAuditObservation::new(status, media)),
1154 )?;
1155 return transport_evidence(request, &response, None, None, true, self.privacy);
1156 },
1157 };
1158 let context_id = rest_leg_identity(request.stage());
1159 let view = match ApiVisibilityComparator::default().capture_view(
1160 context_id,
1161 selection.target_identity(),
1162 ApiSurfaceKind::JsonHttp,
1163 status,
1164 &json,
1165 ) {
1166 Ok(view) => view,
1167 Err(
1168 ApiVisibilityEvidenceError::DepthLimitExceeded { .. }
1169 | ApiVisibilityEvidenceError::NodeLimitExceeded { .. }
1170 | ApiVisibilityEvidenceError::FieldLimitExceeded { .. }
1171 | ApiVisibilityEvidenceError::CanonicalBytesLimitExceeded { .. },
1172 ) => {
1173 self.stop(
1174 RestRuntimeOutcome::Incomplete,
1175 None,
1176 Some(RestAuditObservation::new(status, media)),
1177 )?;
1178 return transport_evidence(request, &response, None, None, true, self.privacy);
1179 },
1180 Err(_) => {
1181 return Err(DecisionExecutorError::new("REST JSON view capture failed"));
1182 },
1183 };
1184 drop(json);
1185
1186 let comparison = if request.stage() == DecisionExecutionStage::Active {
1187 let state = self
1188 .state
1189 .lock()
1190 .map_err(|_| DecisionExecutorError::new("REST review state is unavailable"))?;
1191 let candidate = state
1192 .legs
1193 .get(&DecisionExecutionStage::Passive)
1194 .ok_or_else(|| DecisionExecutorError::new("REST candidate view is missing"))?;
1195 Some(
1196 ApiVisibilityComparator::default()
1197 .compare_exact_replay(&candidate.view, &view)
1198 .map_err(|_| DecisionExecutorError::new("REST replay comparison failed"))?,
1199 )
1200 } else {
1201 None
1202 };
1203 let evidence = transport_evidence(
1204 request,
1205 &response,
1206 Some(&selection),
1207 comparison.as_ref(),
1208 false,
1209 self.privacy,
1210 )?;
1211 let evidence_ids = evidence
1212 .iter()
1213 .filter(|evidence| {
1214 evidence.predicate().namespace() == REST_REVIEW_EVIDENCE_NAMESPACE
1215 && matches!(
1216 evidence.predicate().name(),
1217 "view" | "status-equivalent" | "fields-equivalent" | "resources-equivalent"
1218 )
1219 })
1220 .map(|evidence| evidence.id().clone())
1221 .collect::<Vec<_>>();
1222 if evidence_ids.is_empty() {
1223 return Err(DecisionExecutorError::new("REST evidence is missing"));
1224 }
1225 let response_bytes = u64::try_from(response.body().len()).unwrap_or(u64::MAX);
1226 let replaced = self
1227 .state
1228 .lock()
1229 .map_err(|_| DecisionExecutorError::new("REST review state is unavailable"))?
1230 .legs
1231 .insert(
1232 request.stage(),
1233 RestLeg {
1234 view,
1235 response_bytes,
1236 media,
1237 evidence_ids,
1238 },
1239 );
1240 if replaced.is_some() {
1241 return Err(DecisionExecutorError::new("REST review leg is duplicated"));
1242 }
1243 Ok(evidence)
1244 }
1245}
1246
1247fn observed_media_class(media: Option<&str>) -> RestObservedMediaClass {
1248 match media {
1249 Some("application/json") => RestObservedMediaClass::JsonCompatible,
1250 Some(value) if value.ends_with("+json") => RestObservedMediaClass::JsonCompatible,
1251 Some(value) if value.starts_with("text/") => RestObservedMediaClass::Text,
1252 Some(_) => RestObservedMediaClass::Unsupported,
1253 None => RestObservedMediaClass::Unknown,
1254 }
1255}
1256
1257fn status_outcome(status: u16) -> Option<RestRuntimeOutcome> {
1258 match status {
1259 200..=299 => None,
1260 401 => Some(RestRuntimeOutcome::AuthenticationRequired),
1261 403 => Some(RestRuntimeOutcome::Forbidden),
1262 404 => Some(RestRuntimeOutcome::NotFound),
1263 429 => Some(RestRuntimeOutcome::RateLimited),
1264 500..=599 => Some(RestRuntimeOutcome::ServerError),
1265 _ => Some(RestRuntimeOutcome::Incomplete),
1266 }
1267}
1268
1269fn phase_terminal_evidence(
1270 request: &DecisionExecutionRequest,
1271) -> Result<Vec<Evidence>, DecisionExecutorError> {
1272 Ok(vec![
1273 make_evidence(
1274 request,
1275 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1276 EvidenceValue::Unsigned(0),
1277 "response-body-size",
1278 )?,
1279 make_evidence(
1280 request,
1281 crate::web_actions::rest_review_phase_terminal_predicate(),
1282 EvidenceValue::Boolean(true),
1283 "phase-terminal",
1284 )?,
1285 ])
1286}
1287
1288fn make_evidence(
1289 request: &DecisionExecutionRequest,
1290 predicate: KnowledgePredicate,
1291 value: EvidenceValue,
1292 method: &str,
1293) -> Result<Evidence, DecisionExecutorError> {
1294 Ok(Evidence::new(
1295 request.case().subject().clone(),
1296 EvidenceKind::Custom("rest-review".into()),
1297 predicate,
1298 value,
1299 EvidenceSource::new(REST_REVIEW_EXECUTOR_ID, method)
1300 .and_then(|source| source.with_correlation_id(request.case().id()))
1301 .map_err(|_| DecisionExecutorError::new("REST evidence source failed"))?,
1302 ConfidenceScore::MAX,
1303 ))
1304}
1305
1306fn make_typed_evidence(
1307 request: &DecisionExecutionRequest,
1308 kind: EvidenceKind,
1309 predicate: KnowledgePredicate,
1310 value: EvidenceValue,
1311 method: &str,
1312) -> Result<Evidence, DecisionExecutorError> {
1313 Ok(Evidence::new(
1314 request.case().subject().clone(),
1315 kind,
1316 predicate,
1317 value,
1318 EvidenceSource::new(REST_REVIEW_EXECUTOR_ID, method)
1319 .and_then(|source| source.with_correlation_id(request.case().id()))
1320 .map_err(|_| DecisionExecutorError::new("REST evidence source failed"))?,
1321 ConfidenceScore::MAX,
1322 ))
1323}
1324
1325fn transport_evidence(
1326 request: &DecisionExecutionRequest,
1327 response: &crate::http_evidence::CollectedHttpResponse,
1328 selection: Option<&RestOperationSelection>,
1329 comparison: Option<&ApiExactReplayComparison>,
1330 terminal: bool,
1331 privacy: RestResponsePrivacy,
1332) -> Result<Vec<Evidence>, DecisionExecutorError> {
1333 let digest = (!privacy.is_value_free()).then(|| {
1334 let mut digest = Sha256::new();
1335 digest.update(REST_REVIEW_BODY_DIGEST_DOMAIN);
1336 digest.update(response.body());
1337 format!("{:x}", digest.finalize())
1338 });
1339 let signal = response.openapi_defense_signal();
1340 let mut evidence = vec![
1341 make_typed_evidence(
1342 request,
1343 EvidenceKind::Http,
1344 HttpEvidencePredicate::REQUEST_METHOD.into_knowledge(),
1345 EvidenceValue::Text("GET".into()),
1346 "request-method",
1347 )?,
1348 make_typed_evidence(
1349 request,
1350 EvidenceKind::Http,
1351 HttpEvidencePredicate::REQUEST_URL.into_knowledge(),
1352 EvidenceValue::Text(response.final_url().to_string()),
1353 "request-url",
1354 )?,
1355 make_typed_evidence(
1356 request,
1357 EvidenceKind::Http,
1358 HttpEvidencePredicate::RESPONSE_STATUS.into_knowledge(),
1359 EvidenceValue::Unsigned(u64::from(response.status())),
1360 "response-status",
1361 )?,
1362 make_typed_evidence(
1363 request,
1364 EvidenceKind::Http,
1365 HttpEvidencePredicate::RESPONSE_FINAL_URL.into_knowledge(),
1366 EvidenceValue::Text(response.final_url().to_string()),
1367 "response-final-url",
1368 )?,
1369 make_typed_evidence(
1370 request,
1371 EvidenceKind::Content,
1372 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1373 EvidenceValue::Unsigned(u64::try_from(response.body().len()).unwrap_or(u64::MAX)),
1374 "response-body-size",
1375 )?,
1376 make_typed_evidence(
1377 request,
1378 EvidenceKind::Content,
1379 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED.into_knowledge(),
1380 EvidenceValue::Boolean(response.body_truncated()),
1381 "response-body-truncation",
1382 )?,
1383 make_typed_evidence(
1384 request,
1385 EvidenceKind::RateLimit,
1386 HttpEvidencePredicate::RATE_LIMIT_DETECTED.into_knowledge(),
1387 EvidenceValue::Boolean(response.status() == 429),
1388 "rate-limit-status",
1389 )?,
1390 make_typed_evidence(
1391 request,
1392 EvidenceKind::RateLimit,
1393 HttpEvidencePredicate::RATE_LIMIT_ADVERTISED.into_knowledge(),
1394 EvidenceValue::Boolean(signal.state().has_rate_limit_headers()),
1395 "rate-limit-headers",
1396 )?,
1397 make_evidence(
1398 request,
1399 crate::web_actions::rest_review_phase_terminal_predicate(),
1400 EvidenceValue::Boolean(terminal),
1401 "phase-terminal",
1402 )?,
1403 ];
1404 if let Some(digest) = digest.as_ref() {
1405 evidence.insert(
1407 6,
1408 make_typed_evidence(
1409 request,
1410 EvidenceKind::Content,
1411 HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge(),
1412 EvidenceValue::Text(digest.clone()),
1413 "response-body-sha256",
1414 )?,
1415 );
1416 }
1417 if let Some(media) = response.normalized_media_type() {
1418 evidence.push(make_typed_evidence(
1419 request,
1420 EvidenceKind::Http,
1421 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge(),
1422 EvidenceValue::Text(media),
1423 "response-media-type",
1424 )?);
1425 evidence.push(make_typed_evidence(
1426 request,
1427 EvidenceKind::Http,
1428 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
1429 EvidenceValue::Boolean(response.has_json_compatible_media_type()),
1430 "response-media-type-json-compatibility",
1431 )?);
1432 }
1433 if privacy.is_value_free() {
1434 #[cfg(feature = "secret-exposure-review")]
1435 evidence.push(
1436 super::secret_exposure::project_rest_body_privacy_lineage(
1437 request,
1438 &evidence,
1439 REST_REVIEW_EXECUTOR_ID,
1440 ConfidenceScore::MAX,
1441 )
1442 .map_err(|_| DecisionExecutorError::new("REST privacy lineage failed"))?,
1443 );
1444 #[cfg(not(feature = "secret-exposure-review"))]
1445 return Err(DecisionExecutorError::new(
1446 "REST privacy selection is unavailable",
1447 ));
1448 }
1449 let fixed_predicates = [
1450 HttpEvidencePredicate::REQUEST_METHOD,
1451 HttpEvidencePredicate::REQUEST_URL,
1452 HttpEvidencePredicate::RESPONSE_STATUS,
1453 HttpEvidencePredicate::RESPONSE_FINAL_URL,
1454 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1455 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1456 HttpEvidencePredicate::RESPONSE_BODY_SHA256,
1457 HttpEvidencePredicate::RATE_LIMIT_DETECTED,
1458 HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
1459 ]
1460 .map(|predicate| predicate.into_knowledge());
1461 let defense_parents = evidence
1462 .iter()
1463 .filter(|item| {
1464 #[cfg(not(feature = "secret-exposure-review"))]
1465 let privacy_parent = false;
1466 #[cfg(feature = "secret-exposure-review")]
1467 let privacy_parent = privacy.is_value_free()
1468 && item.predicate().namespace()
1469 == super::secret_exposure::SECRET_EXPOSURE_NAMESPACE
1470 && item.predicate().name() == super::secret_exposure::ACTIVE_REVIEW_BODY_LINEAGE;
1471 fixed_predicates.contains(item.predicate()) || privacy_parent
1472 })
1473 .map(|item| item.id().clone())
1474 .collect();
1475 let classification_parents = evidence
1476 .iter()
1477 .map(|item| item.id().clone())
1478 .collect::<Vec<_>>();
1479 if let Some(selection) = selection {
1480 let leg_identity = rest_leg_identity(request.stage());
1481 let mut values = vec![
1482 format!("leg={leg_identity}"),
1483 format!("status={}", response.status()),
1484 "method=GET".into(),
1485 format!(
1486 "media={}",
1487 observed_media_name(observed_media_class(
1488 response.normalized_media_type().as_deref()
1489 ))
1490 ),
1491 format!("complete={}", response.body_complete()),
1492 format!("truncated={}", response.body_truncated()),
1493 format!("rate_limited={}", signal.state().is_rate_limited()),
1494 format!("challenged={}", signal.state().is_challenged()),
1495 ];
1496 if let Some(digest) = digest.as_ref() {
1497 values.insert(
1498 1,
1499 format!("operation={}", selection.operation_id().as_str()),
1500 );
1501 values.insert(2, format!("target={}", selection.target_identity()));
1502 values.insert(8, format!("response={digest}"));
1503 }
1504 let item = make_evidence(
1505 request,
1506 KnowledgePredicate::new(REST_REVIEW_EVIDENCE_NAMESPACE, "view")
1507 .map_err(|_| DecisionExecutorError::new("REST predicate failed"))?,
1508 EvidenceValue::TextList(values),
1509 leg_identity,
1510 )?;
1511 evidence.push(classification_evidence(
1512 item,
1513 &classification_parents,
1514 privacy,
1515 )?);
1516 }
1517 if let Some(comparison) = comparison {
1518 for (name, equivalent) in [
1519 ("status-equivalent", comparison.status()),
1520 ("fields-equivalent", comparison.fields()),
1521 ("resources-equivalent", comparison.resources()),
1522 ] {
1523 let item = make_evidence(
1524 request,
1525 KnowledgePredicate::new(REST_REVIEW_EVIDENCE_NAMESPACE, name)
1526 .map_err(|_| DecisionExecutorError::new("REST predicate failed"))?,
1527 EvidenceValue::Boolean(equivalent),
1528 name,
1529 )?;
1530 evidence.push(classification_evidence(
1531 item,
1532 &classification_parents,
1533 privacy,
1534 )?);
1535 }
1536 }
1537 if !terminal {
1538 evidence.push(make_evidence(
1539 request,
1540 crate::web_actions::native_web_review_response_marker_predicate(),
1541 EvidenceValue::Boolean(true),
1542 "complete",
1543 )?);
1544 }
1545 evidence.extend(
1546 project_assessment_defense_signal(
1547 &signal,
1548 AssessmentDefenseProjectionContext {
1549 subject: request.case().subject(),
1550 case_id: request.case().id(),
1551 executor_id: REST_REVIEW_EXECUTOR_ID,
1552 reliability: ConfidenceScore::MAX,
1553 parents: defense_parents,
1554 },
1555 )
1556 .map_err(|_| DecisionExecutorError::new("REST defense projection failed"))?,
1557 );
1558 Ok(evidence)
1559}
1560
1561fn classification_evidence(
1562 item: Evidence,
1563 parents: &[EvidenceId],
1564 privacy: RestResponsePrivacy,
1565) -> Result<Evidence, DecisionExecutorError> {
1566 if !privacy.is_value_free() {
1567 return Ok(item);
1568 }
1569 let derivation = termivar_core::EvidenceDerivation::new(
1570 parents.to_vec(),
1571 termivar_core::DerivationAlgorithm::new(REST_CLASSIFICATION_ALGORITHM, 1)
1572 .map_err(|_| DecisionExecutorError::new("REST classification algorithm failed"))?,
1573 )
1574 .map_err(|_| DecisionExecutorError::new("REST classification provenance failed"))?;
1575 Ok(item.derived_from(derivation))
1576}
1577
1578fn rest_leg_identity(stage: DecisionExecutionStage) -> &'static str {
1579 match stage {
1580 DecisionExecutionStage::Passive => "rest-review:candidate",
1581 DecisionExecutionStage::Active => "rest-review:replay",
1582 }
1583}
1584
1585fn observed_media_name(media: RestObservedMediaClass) -> &'static str {
1586 match media {
1587 RestObservedMediaClass::JsonCompatible => "json-compatible",
1588 RestObservedMediaClass::Text => "text",
1589 RestObservedMediaClass::Unsupported => "unsupported",
1590 RestObservedMediaClass::Unknown => "unknown",
1591 }
1592}
1593
1594fn audit(
1595 selection: Option<&RestOperationSelection>,
1596 outcome: RestRuntimeOutcome,
1597 requests: u8,
1598 observation: Option<RestAuditObservation>,
1599 projected: bool,
1600) -> WebAssessmentRestAudit {
1601 WebAssessmentRestAudit {
1602 outcome,
1603 request_count: requests,
1604 active_verification_count: u8::from(requests == MAX_REST_REVIEW_REQUESTS as u8),
1605 eligible_operation_count: selection
1606 .map_or(0, RestOperationSelection::eligible_operation_count),
1607 selected_operation_identity: selection
1608 .map(|selected| selected.operation_id().as_str().to_owned()),
1609 documented_response: selection.map(RestOperationSelection::documented_response),
1610 observed_media: observation.unwrap_or_default().media,
1611 status_class: observation.and_then(|observation| observation.status_class),
1612 replay_stable: projected,
1613 item_projected: projected,
1614 }
1615}
1616
1617fn audit_for_privacy(
1618 selection: Option<&RestOperationSelection>,
1619 outcome: RestRuntimeOutcome,
1620 requests: u8,
1621 observation: Option<RestAuditObservation>,
1622 projected: bool,
1623 privacy: RestResponsePrivacy,
1624) -> WebAssessmentRestAudit {
1625 let mut audit = audit(selection, outcome, requests, observation, projected);
1626 if privacy.is_value_free() {
1627 audit.selected_operation_identity = None;
1628 }
1629 audit
1630}
1631
1632pub(super) fn project_rest_item(
1633 context: &mut AssessmentProjectionContext,
1634 knowledge: &KnowledgeBase,
1635 review: &CommittedRestReview,
1636) -> Result<(), AssessmentItemProjectionError> {
1637 for evidence_id in &review.evidence_ids {
1638 context.register_evidence(knowledge, evidence_id)?;
1639 }
1640 if review.outcome == RestRuntimeOutcome::SurfaceObserved {
1641 let target = if review.privacy.is_value_free() {
1642 AssessmentItemTarget::Subject
1643 } else {
1644 let target_identity = review
1645 .target_identity
1646 .clone()
1647 .ok_or(AssessmentItemProjectionError::InvalidStableSubjectIdentity)?;
1648 AssessmentItemTarget::rest_operation(target_identity)?
1649 };
1650 context.project_observation(
1651 &REST_REVIEW_CAPABILITY,
1652 knowledge,
1653 &review.subject,
1654 &target,
1655 &review.evidence_ids,
1656 )?;
1657 }
1658 Ok(())
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663 use super::*;
1664 use crate::{
1665 openapi_review::{parse_openapi_document, OpenApiParseOutcome},
1666 rest_review::{select_rest_operation, RestOperationSelectionOutcome},
1667 };
1668 use url::Url;
1669
1670 fn selection() -> RestOperationSelection {
1671 let origin = Url::parse("https://example.test/openapi.json").unwrap();
1672 let document = br#"{
1673 "openapi":"3.1.0",
1674 "paths":{"/health":{"get":{"responses":{"200":{"content":{"application/json":{}}}}}}}
1675 }"#;
1676 let OpenApiParseOutcome::Complete(document) = parse_openapi_document(document, &origin)
1677 else {
1678 panic!("fixture must parse");
1679 };
1680 let RestOperationSelectionOutcome::Selected(selection) =
1681 select_rest_operation(&document, &origin)
1682 else {
1683 panic!("fixture must select one operation");
1684 };
1685 selection
1686 }
1687
1688 #[test]
1689 fn stable_selection_slot_is_clone_shared_single_assignment_and_redacted() {
1690 let slot = StableRestSelectionSlot::new();
1691 let clone = slot.clone();
1692 let selected = selection();
1693 let secret_url = selected.execution_url().to_string();
1694 slot.commit(selected.clone()).unwrap();
1695 assert_eq!(clone.selection().unwrap(), Some(selected.clone()));
1696 assert!(matches!(
1697 clone.commit(selected),
1698 Err(RestRuntimeInvariantError::Catalog)
1699 ));
1700 let debug = format!("{slot:?}");
1701 assert!(!debug.contains(&secret_url));
1702 assert!(!debug.contains("/health"));
1703 }
1704
1705 #[test]
1706 fn status_and_media_classification_are_closed_and_conservative() {
1707 assert_eq!(status_outcome(200), None);
1708 assert_eq!(
1709 status_outcome(401),
1710 Some(RestRuntimeOutcome::AuthenticationRequired)
1711 );
1712 assert_eq!(status_outcome(403), Some(RestRuntimeOutcome::Forbidden));
1713 assert_eq!(status_outcome(404), Some(RestRuntimeOutcome::NotFound));
1714 assert_eq!(status_outcome(429), Some(RestRuntimeOutcome::RateLimited));
1715 assert_eq!(status_outcome(503), Some(RestRuntimeOutcome::ServerError));
1716 assert_eq!(status_outcome(418), Some(RestRuntimeOutcome::Incomplete));
1717 assert_eq!(
1718 observed_media_class(Some("application/problem+json")),
1719 RestObservedMediaClass::JsonCompatible
1720 );
1721 assert_eq!(
1722 observed_media_class(Some("text/html")),
1723 RestObservedMediaClass::Text
1724 );
1725 assert_eq!(
1726 observed_media_class(Some("application/octet-stream")),
1727 RestObservedMediaClass::Unsupported
1728 );
1729 assert_eq!(observed_media_class(None), RestObservedMediaClass::Unknown);
1730 assert_eq!(
1731 rest_leg_identity(DecisionExecutionStage::Passive),
1732 "rest-review:candidate"
1733 );
1734 assert_eq!(
1735 rest_leg_identity(DecisionExecutionStage::Active),
1736 "rest-review:replay"
1737 );
1738 assert_ne!(
1739 rest_leg_identity(DecisionExecutionStage::Passive),
1740 rest_leg_identity(DecisionExecutionStage::Active)
1741 );
1742 }
1743
1744 #[test]
1745 fn audit_debug_never_exposes_operation_identity() {
1746 let selected = selection();
1747 let audit = audit(
1748 Some(&selected),
1749 RestRuntimeOutcome::SurfaceObserved,
1750 2,
1751 None,
1752 true,
1753 );
1754 let debug = format!("{audit:?}");
1755 assert!(!debug.contains(selected.operation_id().as_str()));
1756 assert!(!debug.contains(selected.execution_url().path()));
1757 assert_eq!(audit.request_count(), 2);
1758 assert_eq!(audit.active_verification_count(), 1);
1759 assert!(audit.replay_stable() && audit.item_projected());
1760 }
1761
1762 #[cfg(feature = "secret-exposure-review")]
1766 mod privacy_tests {
1767 use super::*;
1768 use crate::{
1769 runtime_budget::RequestAccountingBroker, ActionCost, AdaptationLimits, AttackAction,
1770 BenefitScore, DecisionEvidenceReceipt, DecisionLoop, DecisionLoopCommand,
1771 DecisionLoopConfig, DecisionOutcomeReport, DecisionRunnerAdapter, DecisionSession,
1772 DecisionStopReason, ExperiencePolicy, ExperienceStore, Expression, HypothesisSelector,
1773 KnowledgeLayer, KnowledgeWrite, PlanningContext, RequiredStrength, RiskScore,
1774 RuntimeBudget, RuntimeUsage, StandardWebDecisionRunReport,
1775 StandardWebDecisionRuntimeTurn, VerificationCase, VerificationTarget,
1776 };
1777 use std::collections::BTreeSet;
1778 use termivar_core::{DerivationAlgorithm, EvidenceDerivation, Probability};
1779
1780 const CASE: &str = "case:synthetic-rest-privacy";
1781 const RESOURCE: &str = "https://example.test/health";
1782
1783 struct LiteralExecutor {
1784 id: &'static str,
1785 batch: Vec<Evidence>,
1786 }
1787
1788 #[async_trait]
1789 impl DecisionActionExecutor for LiteralExecutor {
1790 fn id(&self) -> &str {
1791 self.id
1792 }
1793
1794 async fn execute(
1795 &self,
1796 _request: &DecisionExecutionRequest,
1797 ) -> Result<Vec<Evidence>, DecisionExecutorError> {
1798 assert!(self.batch.len() <= 32);
1799 Ok(self.batch.clone())
1800 }
1801 }
1802
1803 fn subject() -> EntityId {
1804 EntityId::new("endpoint:https://example.test/").unwrap()
1805 }
1806
1807 fn fact(
1808 kind: EvidenceKind,
1809 predicate: KnowledgePredicate,
1810 value: EvidenceValue,
1811 method: &str,
1812 ) -> Evidence {
1813 Evidence::new(
1814 subject(),
1815 kind,
1816 predicate,
1817 value,
1818 EvidenceSource::new("http.rest-review", method)
1819 .unwrap()
1820 .with_correlation_id(CASE)
1821 .unwrap(),
1822 ConfidenceScore::MAX,
1823 )
1824 }
1825
1826 fn custom(name: &str, value: EvidenceValue, method: &str) -> Evidence {
1827 fact(
1828 EvidenceKind::Custom("rest-review".to_owned()),
1829 KnowledgePredicate::new("web.rest-review.transport", name).unwrap(),
1830 value,
1831 method,
1832 )
1833 }
1834
1835 fn no_response_batch() -> Vec<Evidence> {
1836 vec![
1837 fact(
1838 EvidenceKind::Custom("rest-review".to_owned()),
1839 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED.into_knowledge(),
1840 EvidenceValue::Unsigned(0),
1841 "response-body-size",
1842 ),
1843 fact(
1844 EvidenceKind::Custom("rest-review".to_owned()),
1845 crate::web_actions::rest_review_phase_terminal_predicate(),
1846 EvidenceValue::Boolean(true),
1847 "phase-terminal",
1848 ),
1849 ]
1850 }
1851
1852 fn received_batch(parsed: bool, stage: DecisionExecutionStage) -> Vec<Evidence> {
1853 let rows = [
1856 (
1857 HttpEvidencePredicate::REQUEST_METHOD,
1858 EvidenceKind::Http,
1859 EvidenceValue::Text("GET".to_owned()),
1860 "request-method",
1861 ),
1862 (
1863 HttpEvidencePredicate::REQUEST_URL,
1864 EvidenceKind::Http,
1865 EvidenceValue::Text(RESOURCE.to_owned()),
1866 "request-url",
1867 ),
1868 (
1869 HttpEvidencePredicate::RESPONSE_STATUS,
1870 EvidenceKind::Http,
1871 EvidenceValue::Unsigned(if parsed { 200 } else { 404 }),
1872 "response-status",
1873 ),
1874 (
1875 HttpEvidencePredicate::RESPONSE_FINAL_URL,
1876 EvidenceKind::Http,
1877 EvidenceValue::Text(RESOURCE.to_owned()),
1878 "response-final-url",
1879 ),
1880 (
1881 HttpEvidencePredicate::RESPONSE_BODY_BYTES_OBSERVED,
1882 EvidenceKind::Content,
1883 EvidenceValue::Unsigned(8),
1884 "response-body-size",
1885 ),
1886 (
1887 HttpEvidencePredicate::RESPONSE_BODY_TRUNCATED,
1888 EvidenceKind::Content,
1889 EvidenceValue::Boolean(false),
1890 "response-body-truncation",
1891 ),
1892 (
1893 HttpEvidencePredicate::RATE_LIMIT_DETECTED,
1894 EvidenceKind::RateLimit,
1895 EvidenceValue::Boolean(false),
1896 "rate-limit-status",
1897 ),
1898 (
1899 HttpEvidencePredicate::RATE_LIMIT_ADVERTISED,
1900 EvidenceKind::RateLimit,
1901 EvidenceValue::Boolean(false),
1902 "rate-limit-headers",
1903 ),
1904 ];
1905 let mut batch = rows
1906 .into_iter()
1907 .map(|(predicate, kind, value, method)| {
1908 fact(kind, predicate.into_knowledge(), value, method)
1909 })
1910 .collect::<Vec<_>>();
1911 batch.push(fact(
1912 EvidenceKind::Custom("rest-review".to_owned()),
1913 crate::web_actions::rest_review_phase_terminal_predicate(),
1914 EvidenceValue::Boolean(!parsed),
1915 "phase-terminal",
1916 ));
1917 batch.push(fact(
1918 EvidenceKind::Http,
1919 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE.into_knowledge(),
1920 EvidenceValue::Text("application/json".to_owned()),
1921 "response-media-type",
1922 ));
1923 batch.push(fact(
1924 EvidenceKind::Http,
1925 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
1926 EvidenceValue::Boolean(true),
1927 "response-media-type-json-compatibility",
1928 ));
1929 let parents: Vec<_> = [0, 1, 2, 3, 4, 5, 9]
1932 .into_iter()
1933 .map(|index| batch[index].id().clone())
1934 .collect();
1935 batch.push(
1936 fact(
1937 EvidenceKind::Custom("passive-secret-exposure".to_owned()),
1938 KnowledgePredicate::new("web.secret-exposure", "active_review_body_lineage")
1939 .unwrap(),
1940 EvidenceValue::Boolean(true),
1941 "active-review-body-digest-privacy-guard",
1942 )
1943 .derived_from(
1944 EvidenceDerivation::new(
1945 parents,
1946 DerivationAlgorithm::new(
1947 "web.secret-exposure.active-review-body-digest-privacy-guard",
1948 1,
1949 )
1950 .unwrap(),
1951 )
1952 .unwrap(),
1953 ),
1954 );
1955 if parsed {
1956 let parents = batch
1957 .iter()
1958 .map(|item| item.id().clone())
1959 .collect::<Vec<_>>();
1960 let (identity, view) = match stage {
1961 DecisionExecutionStage::Passive => (
1962 "rest-review:candidate",
1963 vec![
1964 "leg=rest-review:candidate",
1965 "status=200",
1966 "method=GET",
1967 "media=json-compatible",
1968 "complete=true",
1969 "truncated=false",
1970 "rate_limited=false",
1971 "challenged=false",
1972 ],
1973 ),
1974 DecisionExecutionStage::Active => (
1975 "rest-review:replay",
1976 vec![
1977 "leg=rest-review:replay",
1978 "status=200",
1979 "method=GET",
1980 "media=json-compatible",
1981 "complete=true",
1982 "truncated=false",
1983 "rate_limited=false",
1984 "challenged=false",
1985 ],
1986 ),
1987 };
1988 batch.push(
1989 custom(
1990 "view",
1991 EvidenceValue::TextList(view.into_iter().map(str::to_owned).collect()),
1992 identity,
1993 )
1994 .derived_from(
1995 EvidenceDerivation::new(
1996 parents.clone(),
1997 DerivationAlgorithm::new(
1998 "web.rest-review.value-free-classification",
1999 1,
2000 )
2001 .unwrap(),
2002 )
2003 .unwrap(),
2004 ),
2005 );
2006 if stage == DecisionExecutionStage::Active {
2007 for name in [
2010 "status-equivalent",
2011 "fields-equivalent",
2012 "resources-equivalent",
2013 ] {
2014 batch.push(
2015 custom(name, EvidenceValue::Boolean(true), name).derived_from(
2016 EvidenceDerivation::new(
2017 parents.clone(),
2018 DerivationAlgorithm::new(
2019 "web.rest-review.value-free-classification",
2020 1,
2021 )
2022 .unwrap(),
2023 )
2024 .unwrap(),
2025 ),
2026 );
2027 }
2028 }
2029 batch.push(fact(
2030 EvidenceKind::Custom("rest-review".to_owned()),
2031 crate::web_actions::native_web_review_response_marker_predicate(),
2032 EvidenceValue::Boolean(true),
2033 "complete",
2034 ));
2035 }
2036 batch
2037 }
2038
2039 fn replace(
2040 item: &Evidence,
2041 kind: EvidenceKind,
2042 value: EvidenceValue,
2043 source: EvidenceSource,
2044 parents: Option<Vec<EvidenceId>>,
2045 ) -> Evidence {
2046 let mut result = Evidence::with_id_at(
2047 item.id().clone(),
2048 item.subject().clone(),
2049 kind,
2050 item.predicate().clone(),
2051 value,
2052 source,
2053 item.reliability(),
2054 item.observed_at_ms(),
2055 );
2056 if let Some(original) = item.origin().derivation() {
2057 result = result.derived_from(
2058 EvidenceDerivation::new(
2059 parents.unwrap_or_else(|| original.parents().to_vec()),
2060 original.algorithm().clone(),
2061 )
2062 .unwrap(),
2063 );
2064 }
2065 result
2066 }
2067
2068 async fn commit_route(
2069 knowledge: &KnowledgeBase,
2070 batch: Vec<Evidence>,
2071 action: &str,
2072 executor: &'static str,
2073 stage: DecisionExecutionStage,
2074 ) -> DecisionEvidenceReceipt {
2075 let batch = if executor == "http.rest-review" {
2078 batch
2079 } else {
2080 batch
2081 .into_iter()
2082 .map(|item| {
2083 let source = EvidenceSource::new(executor, item.source().method())
2084 .unwrap()
2085 .with_correlation_id(CASE)
2086 .unwrap();
2087 replace(
2088 &item,
2089 item.kind().clone(),
2090 item.value().clone(),
2091 source,
2092 None,
2093 )
2094 })
2095 .collect()
2096 };
2097 let mut registry = DecisionExecutorRegistry::new();
2098 registry
2099 .register(Arc::new(LiteralExecutor {
2100 id: executor,
2101 batch,
2102 }))
2103 .unwrap();
2104 registry.route_action(stage, action, executor).unwrap();
2105 let case = VerificationCase::new(CASE, subject(), action, "hypothesis:synthetic-rest")
2106 .unwrap()
2107 .without_hypothesis_transition();
2108 let command = match stage {
2109 DecisionExecutionStage::Passive => DecisionLoopCommand::ExecuteAction {
2110 case,
2111 executor: None,
2112 origin: DecisionActionOrigin::Planned,
2113 delay_ms: None,
2114 },
2115 DecisionExecutionStage::Active => {
2116 DecisionLoopCommand::CollectActiveEvidence { case }
2117 },
2118 };
2119 DecisionRunnerAdapter::new(registry)
2120 .execute_command(&command, knowledge)
2121 .await
2122 .unwrap()
2123 }
2124
2125 async fn committed(batch: Vec<Evidence>) -> (KnowledgeBase, DecisionEvidenceReceipt) {
2126 let knowledge = KnowledgeBase::new();
2127 let receipt = commit_route(
2128 &knowledge,
2129 batch,
2130 "web.review.rest.readonly-replay@1",
2131 "http.rest-review",
2132 DecisionExecutionStage::Passive,
2133 )
2134 .await;
2135 (knowledge, receipt)
2136 }
2137
2138 fn report(
2139 receipt: Option<DecisionEvidenceReceipt>,
2140 received: bool,
2141 ) -> StandardWebDecisionRunReport {
2142 let accounting = RequestAccountingBroker::new(RuntimeBudget::default());
2143 if received {
2144 let mut lease = accounting
2145 .try_begin(
2146 "web.review.rest.readonly-replay@1",
2147 DecisionExecutionStage::Passive,
2148 Some(DecisionActionOrigin::Planned),
2149 )
2150 .unwrap();
2151 assert_eq!(lease.observe_response_bytes(8), 8);
2152 lease.finish(TransportDispatchOutcome::Completed);
2153 }
2154 StandardWebDecisionRunReport {
2155 bootstrap: None,
2156 turns: Vec::new(),
2157 unverified_evidence: receipt,
2158 terminal: DecisionLoopCommand::Halt {
2159 reason: DecisionStopReason::NoEligibleAction,
2160 },
2161 usage: RuntimeUsage::default(),
2162 transport: accounting.dispatch_audit(),
2163 limit_exceeded: None,
2164 execution_failure: None,
2165 }
2166 }
2167
2168 fn terminal_state() -> RestExecutionState {
2169 RestExecutionState {
2170 legs: BTreeMap::new(),
2171 terminal: Some(RestRuntimeOutcome::NotFound),
2172 terminal_observation: Some(RestAuditObservation::new(
2173 404,
2174 RestObservedMediaClass::JsonCompatible,
2175 )),
2176 runtime_limit: None,
2177 }
2178 }
2179
2180 fn parsed_leg(batch: &[Evidence], stage: DecisionExecutionStage) -> RestLeg {
2181 let identity = match stage {
2182 DecisionExecutionStage::Passive => "rest-review:candidate",
2183 DecisionExecutionStage::Active => "rest-review:replay",
2184 };
2185 let names: &[&str] = if stage == DecisionExecutionStage::Passive {
2186 &["view"]
2187 } else {
2188 &[
2189 "view",
2190 "status-equivalent",
2191 "fields-equivalent",
2192 "resources-equivalent",
2193 ]
2194 };
2195 RestLeg {
2196 view: ApiVisibilityComparator::default()
2199 .capture_view(
2200 identity,
2201 super::selection().target_identity(),
2202 ApiSurfaceKind::JsonHttp,
2203 200,
2204 &serde_json::json!({"ok": 1}),
2205 )
2206 .unwrap(),
2207 response_bytes: 8,
2208 media: RestObservedMediaClass::JsonCompatible,
2209 evidence_ids: names
2210 .iter()
2211 .map(|name| {
2212 batch
2213 .iter()
2214 .find(|item| {
2215 item.predicate().namespace() == "web.rest-review.transport"
2216 && item.predicate().name() == *name
2217 })
2218 .unwrap()
2219 .id()
2220 .clone()
2221 })
2222 .collect(),
2223 }
2224 }
2225
2226 fn parsed_state(batch: &[Evidence]) -> RestExecutionState {
2227 RestExecutionState {
2228 legs: BTreeMap::from([(
2229 DecisionExecutionStage::Passive,
2230 parsed_leg(batch, DecisionExecutionStage::Passive),
2231 )]),
2232 ..RestExecutionState::default()
2233 }
2234 }
2235
2236 fn validate(
2237 receipt: Option<DecisionEvidenceReceipt>,
2238 knowledge: &KnowledgeBase,
2239 state: &RestExecutionState,
2240 received: bool,
2241 ) -> Result<bool, RestRuntimeInvariantError> {
2242 validate_value_free_rest_commits(
2243 &report(receipt, received),
2244 knowledge,
2245 &subject(),
2246 Some(&super::selection()),
2247 state,
2248 )
2249 }
2250
2251 fn mutation(
2252 template: &DecisionEvidenceReceipt,
2253 batch: Vec<Evidence>,
2254 ) -> (KnowledgeBase, DecisionEvidenceReceipt) {
2255 let knowledge = KnowledgeBase::new();
2256 let writes = knowledge.insert_evidence_batch(batch.clone()).unwrap();
2257 let receipt = template.with_test_committed_batch(
2258 batch,
2259 writes,
2260 knowledge.snapshot_for_subject(&subject()),
2261 );
2262 (knowledge, receipt)
2263 }
2264
2265 fn decision_holder(
2268 knowledge: &KnowledgeBase,
2269 receipt: &DecisionEvidenceReceipt,
2270 ) -> DecisionOutcomeReport {
2271 let planning = PlanningContext::new(
2272 BenefitScore::from_percent(90).unwrap(),
2273 100,
2274 RiskScore::from_percent(50).unwrap(),
2275 );
2276 let config = DecisionLoopConfig::new(
2277 planning,
2278 AdaptationLimits::default(),
2279 ExperiencePolicy::default(),
2280 1,
2281 )
2282 .unwrap();
2283 let mut decision = DecisionLoop::new(config);
2284 let predicate = KnowledgePredicate::new("synthetic", "rest-holder").unwrap();
2285 let holder_hypothesis = termivar_core::Hypothesis::with_id(
2286 "hypothesis:synthetic-rest",
2287 subject(),
2288 predicate.clone(),
2289 EvidenceValue::Boolean(true),
2290 Probability::from_percent(50).unwrap(),
2291 )
2292 .unwrap();
2293 knowledge.upsert_hypothesis(holder_hypothesis).unwrap();
2294 let action = AttackAction::new(
2295 "web.review.rest.readonly-replay@1",
2296 "http.rest-review",
2297 Expression::equals(
2298 KnowledgeLayer::Hypothesis,
2299 predicate.clone(),
2300 EvidenceValue::Boolean(true),
2301 ),
2302 HypothesisSelector::new(
2303 predicate,
2304 EvidenceValue::Boolean(true),
2305 Probability::from_percent(50).unwrap(),
2306 RequiredStrength::Strong,
2307 ),
2308 BenefitScore::from_percent(80).unwrap(),
2309 ActionCost::new(10).unwrap(),
2310 RiskScore::from_percent(10).unwrap(),
2311 BTreeSet::new(),
2312 )
2313 .unwrap()
2314 .with_verification_target(VerificationTarget::KnowledgeOnly);
2315 decision.planner_mut().register(action).unwrap();
2316 let mut wire = serde_json::to_value(DecisionSession::new(subject())).unwrap();
2317 wire["action_cycles"] = serde_json::json!(1);
2318 wire["state"] =
2319 serde_json::json!({"state": "awaiting_passive", "case": receipt.case()});
2320 let mut session: DecisionSession = serde_json::from_value(wire).unwrap();
2321 let suppressions = crate::planner::ActionSuppressionContext::new(
2324 BTreeSet::new(),
2325 BTreeSet::from(["web.review.rest.readonly-replay@1".to_owned()]),
2326 );
2327 decision
2328 .submit_passive_with_action_suppressions(
2329 knowledge,
2330 &mut ExperienceStore::new(),
2331 &mut session,
2332 &suppressions,
2333 )
2334 .unwrap()
2335 }
2336
2337 #[tokio::test]
2338 async fn rest_privacy_commits_require_receipts_and_accept_literal_candidate_replay_and_terminals(
2339 ) {
2340 let knowledge = KnowledgeBase::new();
2341 assert!(!validate(None, &knowledge, &terminal_state(), false).unwrap());
2342 let candidate = received_batch(true, DecisionExecutionStage::Passive);
2343 assert!(!validate(None, &knowledge, &parsed_state(&candidate), true).unwrap());
2344 let (knowledge, receipt) = committed(no_response_batch()).await;
2345 assert!(validate(Some(receipt), &knowledge, &terminal_state(), false).unwrap());
2346 let (knowledge, receipt) =
2347 committed(received_batch(false, DecisionExecutionStage::Passive)).await;
2348 assert!(validate(Some(receipt), &knowledge, &terminal_state(), true).unwrap());
2349
2350 let replay = received_batch(true, DecisionExecutionStage::Active);
2351 let state = RestExecutionState {
2352 legs: BTreeMap::from([
2353 (
2354 DecisionExecutionStage::Passive,
2355 parsed_leg(&candidate, DecisionExecutionStage::Passive),
2356 ),
2357 (
2358 DecisionExecutionStage::Active,
2359 parsed_leg(&replay, DecisionExecutionStage::Active),
2360 ),
2361 ]),
2362 ..RestExecutionState::default()
2363 };
2364 let (knowledge, first) = committed(candidate).await;
2365 let holder = decision_holder(&knowledge, &first);
2366 let second = commit_route(
2367 &knowledge,
2368 replay,
2369 "web.review.rest.readonly-replay@1",
2370 "http.rest-review",
2371 DecisionExecutionStage::Active,
2372 )
2373 .await;
2374 let accounting = RequestAccountingBroker::new(RuntimeBudget::default());
2375 for stage in [
2376 DecisionExecutionStage::Passive,
2377 DecisionExecutionStage::Active,
2378 ] {
2379 let mut lease = accounting
2380 .try_begin(
2381 "web.review.rest.readonly-replay@1",
2382 stage,
2383 (stage == DecisionExecutionStage::Passive)
2384 .then_some(DecisionActionOrigin::Planned),
2385 )
2386 .unwrap();
2387 assert_eq!(lease.observe_response_bytes(8), 8);
2388 lease.finish(TransportDispatchOutcome::Completed);
2389 }
2390 let mut standard = report(Some(second), false);
2391 standard
2392 .turns
2393 .push(StandardWebDecisionRuntimeTurn::Outcome {
2394 evidence: Box::new(first),
2395 decision: Box::new(holder),
2396 });
2397 standard.transport = accounting.dispatch_audit();
2398 assert!(validate_value_free_rest_commits(
2399 &standard,
2400 &knowledge,
2401 &subject(),
2402 Some(&super::selection()),
2403 &state
2404 )
2405 .unwrap());
2406 let staged_candidate = received_batch(true, DecisionExecutionStage::Passive);
2409 let staged_replay = received_batch(true, DecisionExecutionStage::Active);
2410 let staged_state = RestExecutionState {
2411 legs: BTreeMap::from([
2412 (
2413 DecisionExecutionStage::Passive,
2414 parsed_leg(&staged_candidate, DecisionExecutionStage::Passive),
2415 ),
2416 (
2417 DecisionExecutionStage::Active,
2418 parsed_leg(&staged_replay, DecisionExecutionStage::Active),
2419 ),
2420 ]),
2421 ..RestExecutionState::default()
2422 };
2423 let (knowledge, first) = committed(staged_candidate).await;
2424 assert!(!validate(Some(first), &knowledge, &staged_state, true).unwrap());
2425 }
2426
2427 #[tokio::test]
2428 async fn rest_privacy_commits_reject_rate_contradiction() {
2429 let batch = received_batch(true, DecisionExecutionStage::Passive);
2430 let state = parsed_state(&batch);
2431 let (_, template) = committed(batch.clone()).await;
2432 let mut contradicted = batch.clone();
2433 let item = &batch[6];
2434 contradicted[6] = replace(
2435 item,
2436 item.kind().clone(),
2437 EvidenceValue::Boolean(true),
2438 item.source().clone(),
2439 None,
2440 );
2441 let (knowledge, receipt) = mutation(&template, contradicted);
2442 assert!(
2443 validate(Some(receipt), &knowledge, &state, true).is_err(),
2444 "a committed rate-limit=true cannot support a positive rate_limited=false view"
2445 );
2446 }
2447
2448 #[tokio::test]
2449 async fn rest_privacy_commits_reject_terminal_media_spoofs() {
2450 let batch = received_batch(false, DecisionExecutionStage::Passive);
2451 let (_, template) = committed(batch.clone()).await;
2452 let compatibility = &batch[10];
2453 let wrong_source = EvidenceSource::new("http.rest-review", "synthetic-wrong-method")
2454 .unwrap()
2455 .with_correlation_id(CASE)
2456 .unwrap();
2457 let replacements = [
2458 replace(
2459 compatibility,
2460 EvidenceKind::Content,
2461 compatibility.value().clone(),
2462 compatibility.source().clone(),
2463 None,
2464 ),
2465 replace(
2466 compatibility,
2467 compatibility.kind().clone(),
2468 compatibility.value().clone(),
2469 wrong_source,
2470 None,
2471 ),
2472 replace(
2473 compatibility,
2474 compatibility.kind().clone(),
2475 EvidenceValue::Text("true".to_owned()),
2476 compatibility.source().clone(),
2477 None,
2478 ),
2479 replace(
2480 compatibility,
2481 compatibility.kind().clone(),
2482 EvidenceValue::Unsigned(1),
2483 compatibility.source().clone(),
2484 None,
2485 ),
2486 replace(
2487 compatibility,
2488 compatibility.kind().clone(),
2489 EvidenceValue::Text("null".to_owned()),
2490 compatibility.source().clone(),
2491 None,
2492 ),
2493 ];
2494 for replacement in replacements {
2495 let mut changed = batch.clone();
2496 changed[10] = replacement;
2497 let (knowledge, receipt) = mutation(&template, changed);
2498 assert!(validate(Some(receipt), &knowledge, &terminal_state(), true).is_err());
2499 }
2500 let mut missing = batch.clone();
2501 missing.remove(10);
2502 let (knowledge, receipt) = mutation(&template, missing);
2503 assert!(validate(Some(receipt), &knowledge, &terminal_state(), true).is_err());
2504 let mut duplicate = batch;
2505 duplicate.push(fact(
2506 EvidenceKind::Http,
2507 HttpEvidencePredicate::RESPONSE_MEDIA_TYPE_JSON_COMPATIBLE.into_knowledge(),
2508 EvidenceValue::Boolean(true),
2509 "response-media-type-json-compatibility",
2510 ));
2511 let (knowledge, receipt) = mutation(&template, duplicate);
2512 assert!(validate(Some(receipt), &knowledge, &terminal_state(), true).is_err());
2513 assert!(serde_json::from_value::<EvidenceValue>(serde_json::Value::Null).is_err());
2516 }
2517
2518 #[tokio::test]
2519 async fn rest_privacy_commits_reject_wrong_route_stage_executor_subject_and_write_authority(
2520 ) {
2521 for (action, executor, stage) in [
2522 (
2523 "synthetic.other-action",
2524 "http.rest-review",
2525 DecisionExecutionStage::Passive,
2526 ),
2527 (
2528 "web.review.rest.readonly-replay@1",
2529 "synthetic.other-executor",
2530 DecisionExecutionStage::Passive,
2531 ),
2532 (
2533 "web.review.rest.readonly-replay@1",
2534 "http.rest-review",
2535 DecisionExecutionStage::Active,
2536 ),
2537 ] {
2538 let knowledge = KnowledgeBase::new();
2539 let receipt =
2540 commit_route(&knowledge, no_response_batch(), action, executor, stage).await;
2541 assert!(validate(Some(receipt), &knowledge, &terminal_state(), false).is_err());
2542 }
2543 let (knowledge, receipt) = committed(no_response_batch()).await;
2544 assert!(validate_value_free_rest_commits(
2545 &report(Some(receipt), false),
2546 &knowledge,
2547 &EntityId::new("endpoint:https://sibling.invalid/").unwrap(),
2548 Some(&super::selection()),
2549 &terminal_state()
2550 )
2551 .is_err());
2552 let batch = received_batch(true, DecisionExecutionStage::Passive);
2553 let state = parsed_state(&batch);
2554 let (knowledge, receipt) = committed(batch).await;
2555 assert!(validate_value_free_rest_commits(
2556 &report(Some(receipt), true),
2557 &knowledge,
2558 &subject(),
2559 None,
2560 &state
2561 )
2562 .is_err());
2563
2564 let batch = received_batch(true, DecisionExecutionStage::Passive);
2565 let state = parsed_state(&batch);
2566 let (knowledge, receipt) = committed(batch.clone()).await;
2567 assert!(validate(Some(receipt.clone()), &KnowledgeBase::new(), &state, true).is_err());
2568 let mut duplicate = batch;
2569 duplicate.push(duplicate[0].clone());
2570 let duplicated = receipt.with_test_committed_batch(
2571 duplicate.clone(),
2572 vec![KnowledgeWrite::Unchanged; duplicate.len()],
2573 knowledge.snapshot_for_subject(&subject()),
2574 );
2575 assert!(validate(Some(duplicated), &knowledge, &state, true).is_err());
2576 let wrong_writes = receipt.with_test_committed_batch(
2577 receipt.evidence().to_vec(),
2578 Vec::new(),
2579 knowledge.snapshot_for_subject(&subject()),
2580 );
2581 assert!(validate(Some(wrong_writes), &knowledge, &state, true).is_err());
2582 }
2583
2584 #[tokio::test]
2585 async fn rest_privacy_commits_reject_content_identities_unknown_facts_and_lost_derivation_parents(
2586 ) {
2587 let batch = received_batch(true, DecisionExecutionStage::Passive);
2588 let state = parsed_state(&batch);
2589 let (_, template) = committed(batch.clone()).await;
2590 for extra in [
2591 fact(
2592 EvidenceKind::Content,
2593 HttpEvidencePredicate::RESPONSE_BODY_SHA256.into_knowledge(),
2594 EvidenceValue::Text("a".repeat(64)),
2595 "response-body-sha256",
2596 ),
2597 custom(
2598 "semantic_digest",
2599 EvidenceValue::Text("synthetic-unkeyed-content-identity".to_owned()),
2600 "semantic-digest",
2601 ),
2602 fact(
2603 EvidenceKind::Custom("passive-secret-exposure".to_owned()),
2604 KnowledgePredicate::new("web.secret-exposure", "response_body_lineage")
2605 .unwrap(),
2606 EvidenceValue::Boolean(true),
2607 "synthetic-extra-lineage",
2608 ),
2609 ] {
2610 let mut changed = batch.clone();
2611 changed.push(extra);
2612 let (knowledge, receipt) = mutation(&template, changed);
2613 assert!(validate(Some(receipt), &knowledge, &state, true).is_err());
2614 }
2615 for index in [11, 12] {
2616 let mut changed = batch.clone();
2617 let item = &batch[index];
2618 let parents = item.origin().derivation().unwrap().parents()[1..].to_vec();
2619 changed[index] = replace(
2620 item,
2621 item.kind().clone(),
2622 item.value().clone(),
2623 item.source().clone(),
2624 Some(parents),
2625 );
2626 let (knowledge, receipt) = mutation(&template, changed);
2627 assert!(validate(Some(receipt), &knowledge, &state, true).is_err());
2628 }
2629 }
2630 }
2631}