1use crate::event_bus::EventBus;
16use crate::http_evidence::HttpProbeMethod;
17use crate::knowledge::KnowledgeBase;
18use crate::legacy_discovery::{
19 BoundedHttpResponse, DiscoveryDelta, DiscoveryForm, DiscoveryLimits, DiscoverySnapshot,
20 LegacyDiscoveryAuthority, LegacyVerificationAuthority, VerificationLimits,
21};
22use crate::logging::{LogLevel, Logger};
23use crate::ScannerError;
24use dashmap::{DashMap, DashSet};
25use reqwest::Client;
26use sha2::{Digest, Sha256};
27use std::{
28 collections::{BTreeMap, BTreeSet},
29 sync::Arc,
30 sync::Mutex,
31};
32use termivar_core::{
33 EntityId, OutcomeStatus, RunOutcomeRecord, VerificationStage, MAX_RUN_REPORT_OUTCOMES,
34};
35use tokio_util::sync::CancellationToken;
36use url::Url;
37
38use crate::verification::VerificationReport;
39
40const LEGACY_VERIFICATION_REDACTED_SUMMARY: &str =
41 "Verifier-backed bounded legacy evidence; raw response content is withheld.";
42const LEGACY_SQL_ACTION_ID: &str = "legacy.observer.sql_behavior";
43const LEGACY_SSTI_ACTION_ID: &str = "legacy.observer.template_arithmetic";
44const LEGACY_LFI_ACTION_ID: &str = "legacy.verification.local-file-canary";
45
46pub(crate) fn legacy_subject_digest(origin: &str) -> String {
47 let mut digest = Sha256::new();
48 digest.update((origin.len() as u64).to_be_bytes());
49 digest.update(origin.as_bytes());
50 digest
51 .finalize()
52 .iter()
53 .map(|byte| format!("{byte:02x}"))
54 .collect()
55}
56
57#[derive(Clone)]
101#[non_exhaustive]
102pub struct ScanContext {
103 pub target: Url,
108 pub client: Arc<Client>,
114 pub discovered_endpoints: Arc<DashMap<String, Vec<String>>>,
116 pub visited_urls: Arc<DashSet<String>>,
118 pub telemetry_tx: tokio::sync::mpsc::UnboundedSender<String>,
120 pub logger: Arc<Logger>,
122 pub phase_timeout_secs: u64,
124 pub cancel_token: CancellationToken,
130 pub event_bus: Arc<EventBus>,
132 knowledge: KnowledgeBase,
135 authorized_target: Url,
138 runtime_cancel_token: CancellationToken,
142 discovery: LegacyDiscoveryAuthority,
144 verification: LegacyVerificationAuthority,
146 discovery_bridge: Arc<Mutex<()>>,
149 legacy_verification_outcomes: Arc<Mutex<Vec<RunOutcomeRecord>>>,
153}
154
155impl ScanContext {
156 pub fn new(
159 target: Url,
160 client: Client,
161 telemetry_tx: tokio::sync::mpsc::UnboundedSender<String>,
162 ) -> Self {
163 Self::with_timeout(target, client, telemetry_tx, 300) }
165
166 pub fn new_with_discovery_limits(
169 target: Url,
170 client: Client,
171 telemetry_tx: tokio::sync::mpsc::UnboundedSender<String>,
172 discovery_limits: DiscoveryLimits,
173 ) -> Self {
174 Self::new(target, client, telemetry_tx)
175 .with_pre_execution_discovery_limits(discovery_limits)
176 }
177
178 pub fn new_with_verification_limits(
181 target: Url,
182 client: Client,
183 telemetry_tx: tokio::sync::mpsc::UnboundedSender<String>,
184 verification_limits: VerificationLimits,
185 ) -> Self {
186 Self::new(target, client, telemetry_tx)
187 .with_pre_execution_verification_limits(verification_limits)
188 }
189
190 pub fn with_timeout(
194 target: Url,
195 client: Client,
196 telemetry_tx: tokio::sync::mpsc::UnboundedSender<String>,
197 phase_timeout_secs: u64,
198 ) -> Self {
199 Self::with_cancellation(
200 target,
201 client,
202 telemetry_tx,
203 phase_timeout_secs,
204 CancellationToken::new(),
205 )
206 }
207
208 pub fn with_cancellation(
212 target: Url,
213 client: Client,
214 telemetry_tx: tokio::sync::mpsc::UnboundedSender<String>,
215 phase_timeout_secs: u64,
216 cancel_token: CancellationToken,
217 ) -> Self {
218 Self::with_event_bus(
219 target,
220 client,
221 telemetry_tx,
222 phase_timeout_secs,
223 cancel_token,
224 Arc::new(EventBus::new()),
225 )
226 }
227
228 pub fn with_event_bus(
235 target: Url,
236 client: Client,
237 telemetry_tx: tokio::sync::mpsc::UnboundedSender<String>,
238 phase_timeout_secs: u64,
239 cancel_token: CancellationToken,
240 event_bus: Arc<EventBus>,
241 ) -> Self {
242 let discovery = LegacyDiscoveryAuthority::new(
243 &target,
244 DiscoveryLimits::default(),
245 cancel_token.clone(),
246 );
247 let verification = LegacyVerificationAuthority::new(
248 &target,
249 VerificationLimits::default(),
250 cancel_token.clone(),
251 );
252 let discovered_endpoints = Arc::new(DashMap::new());
253 for (url, parameters) in discovery.snapshot().endpoints() {
254 discovered_endpoints.insert(url.clone(), parameters.iter().cloned().collect());
255 }
256 Self {
257 target: target.clone(),
258 client: Arc::new(client),
259 discovered_endpoints,
260 visited_urls: Arc::new(DashSet::new()),
261 telemetry_tx,
262 logger: Arc::new(Logger::new(LogLevel::Info)),
263 phase_timeout_secs,
264 cancel_token: cancel_token.clone(),
265 event_bus,
266 knowledge: KnowledgeBase::new(),
267 authorized_target: target,
268 runtime_cancel_token: cancel_token,
269 discovery,
270 verification,
271 discovery_bridge: Arc::new(Mutex::new(())),
272 legacy_verification_outcomes: Arc::new(Mutex::new(Vec::new())),
273 }
274 }
275
276 pub(crate) fn with_pre_execution_discovery_limits(mut self, limits: DiscoveryLimits) -> Self {
277 self.discovery = LegacyDiscoveryAuthority::new(
282 &self.authorized_target,
283 limits,
284 self.runtime_cancel_token.clone(),
285 );
286 self.discovered_endpoints.clear();
287 for (url, parameters) in self.discovery.snapshot().endpoints() {
288 self.discovered_endpoints
289 .insert(url.clone(), parameters.iter().cloned().collect());
290 }
291 self
292 }
293
294 pub const fn discovery_limits(&self) -> DiscoveryLimits {
296 self.discovery.limits()
297 }
298
299 pub(crate) fn with_pre_execution_verification_limits(
300 mut self,
301 limits: VerificationLimits,
302 ) -> Self {
303 self.verification = LegacyVerificationAuthority::new(
307 &self.authorized_target,
308 limits,
309 self.runtime_cancel_token.clone(),
310 );
311 self
312 }
313
314 pub const fn verification_limits(&self) -> VerificationLimits {
317 self.verification.limits()
318 }
319
320 pub(crate) const fn authorized_target(&self) -> &Url {
321 &self.authorized_target
322 }
323
324 pub(crate) fn runtime_cancel_token(&self) -> CancellationToken {
325 self.runtime_cancel_token.clone()
326 }
327
328 pub(crate) fn runtime_authority_is_intact(&self) -> bool {
329 self.target == self.authorized_target
330 }
331
332 pub(crate) async fn request(
333 &self,
334 action_id: &str,
335 method: HttpProbeMethod,
336 url: Url,
337 ) -> Result<BoundedHttpResponse, ScannerError> {
338 self.discovery.request(action_id, method, url).await
339 }
340
341 pub(crate) async fn verification_request(
342 &self,
343 action_id: &str,
344 method: HttpProbeMethod,
345 url: Url,
346 ) -> Result<BoundedHttpResponse, ScannerError> {
347 self.verification.request(action_id, method, url).await
348 }
349
350 pub(crate) fn ensure_legacy_verification_commit(
351 &self,
352 action_id: &str,
353 ) -> Result<(), ScannerError> {
354 self.verification.ensure_commit_allowed(action_id)
355 }
356
357 pub(crate) fn legacy_verification_subject(&self) -> Result<EntityId, ScannerError> {
363 let origin = self.authorized_target.origin().ascii_serialization();
364 EntityId::new(format!(
365 "authorized-origin:sha256:{}",
366 legacy_subject_digest(&origin)
367 ))
368 .map_err(|_| ScannerError::InvalidLegacyVerificationReport)
369 }
370
371 pub(crate) fn record_legacy_verification_reports(
380 &self,
381 reports: Vec<VerificationReport>,
382 ) -> Result<(), ScannerError> {
383 let Some(first_report) = reports.first() else {
384 return Ok(());
385 };
386 let batch_action_id = first_report.case().action_id();
387 let max_reports = match batch_action_id {
388 LEGACY_SQL_ACTION_ID => 2,
389 LEGACY_SSTI_ACTION_ID | LEGACY_LFI_ACTION_ID => 1,
390 _ => return Err(ScannerError::InvalidLegacyVerificationReport),
391 };
392 if reports.len() > max_reports
393 || reports
394 .iter()
395 .any(|report| report.case().action_id() != batch_action_id)
396 {
397 return Err(ScannerError::InvalidLegacyVerificationReport);
398 }
399 self.ensure_legacy_verification_commit(batch_action_id)?;
400
401 let expected_subject = self.legacy_verification_subject()?;
402 let mut accepted = Vec::with_capacity(reports.len());
403 for report in reports {
404 let action_id = report.case().action_id();
405 if report.case().applies_hypothesis_transition()
406 || report.stage() != VerificationStage::Active
407 || report.outcome().status() != OutcomeStatus::NeedsReview
408 || report.case().subject() != &expected_subject
409 || report.outcome().subject() != &expected_subject
410 || !matches!(
411 action_id,
412 LEGACY_SQL_ACTION_ID | LEGACY_SSTI_ACTION_ID | LEGACY_LFI_ACTION_ID
413 )
414 {
415 return Err(ScannerError::InvalidLegacyVerificationReport);
416 }
417
418 let outcome = report.outcome();
419 if outcome.case_id() != report.case().id() {
420 return Err(ScannerError::InvalidLegacyVerificationReport);
421 }
422 let hypothesis = self
423 .knowledge
424 .hypothesis(outcome.hypothesis_id())
425 .ok_or(ScannerError::InvalidLegacyVerificationReport)?;
426 if hypothesis.subject() != &expected_subject
427 || outcome.evidence_ids().iter().any(|evidence_id| {
428 self.knowledge.evidence(evidence_id).is_none_or(|evidence| {
429 evidence.subject() != &expected_subject
430 || evidence.source().component() != action_id
431 || evidence.source().correlation_id() != Some(report.case().id())
432 })
433 })
434 {
435 return Err(ScannerError::InvalidLegacyVerificationReport);
436 }
437 report
438 .apply(&self.knowledge)
439 .map_err(|_| ScannerError::InvalidLegacyVerificationReport)?;
440 accepted.push(RunOutcomeRecord::from_outcome(
441 report.outcome().clone(),
442 LEGACY_VERIFICATION_REDACTED_SUMMARY,
443 )?);
444 }
445
446 let mut ledger = self
447 .legacy_verification_outcomes
448 .lock()
449 .unwrap_or_else(std::sync::PoisonError::into_inner);
450 let next_len = ledger
451 .len()
452 .checked_add(accepted.len())
453 .ok_or(ScannerError::LegacyVerificationStateLimitExceeded)?;
454 if next_len > MAX_RUN_REPORT_OUTCOMES {
455 return Err(ScannerError::LegacyVerificationStateLimitExceeded);
456 }
457 let mut fingerprints = ledger
458 .iter()
459 .map(|record| record.fingerprint())
460 .collect::<BTreeSet<_>>();
461 if accepted
462 .iter()
463 .any(|record| !fingerprints.insert(record.fingerprint()))
464 {
465 return Err(ScannerError::InvalidLegacyVerificationReport);
466 }
467 ledger.extend(accepted);
468 Ok(())
469 }
470
471 pub(crate) fn legacy_verification_checkpoint(&self) -> usize {
472 self.legacy_verification_outcomes
473 .lock()
474 .unwrap_or_else(std::sync::PoisonError::into_inner)
475 .len()
476 }
477
478 pub(crate) fn legacy_verification_outcomes_since(
479 &self,
480 checkpoint: usize,
481 ) -> Vec<RunOutcomeRecord> {
482 let ledger = self
483 .legacy_verification_outcomes
484 .lock()
485 .unwrap_or_else(std::sync::PoisonError::into_inner);
486 ledger.get(checkpoint..).unwrap_or_default().to_vec()
487 }
488
489 pub(crate) fn rollback_legacy_verification_outcomes(&self, checkpoint: usize) {
490 self.legacy_verification_outcomes
491 .lock()
492 .unwrap_or_else(std::sync::PoisonError::into_inner)
493 .truncate(checkpoint);
494 }
495
496 pub(crate) fn canonicalize_discovery_url(&self, url: &Url) -> Result<Url, ScannerError> {
497 self.discovery.canonicalize(url)
498 }
499
500 pub(crate) fn discovery_snapshot(&self) -> DiscoverySnapshot {
501 let _bridge = self
509 .discovery_bridge
510 .lock()
511 .unwrap_or_else(std::sync::PoisonError::into_inner);
512 let legacy_endpoints = self
513 .discovered_endpoints
514 .iter()
515 .map(|entry| (entry.key().clone(), entry.value().clone()))
516 .collect::<BTreeMap<_, _>>();
517 let legacy_visited = self
518 .visited_urls
519 .iter()
520 .map(|entry| entry.key().clone())
521 .collect::<std::collections::BTreeSet<_>>();
522 let mut snapshot = self.discovery.snapshot();
523 for (raw_url, parameters) in legacy_endpoints {
524 let Ok(url) = self.authorized_target.join(&raw_url) else {
525 continue;
526 };
527 let Ok(url) = self.discovery.canonicalize(&url) else {
528 continue;
529 };
530 snapshot.merge_endpoint(url, parameters);
531 }
532 for raw_url in legacy_visited {
533 let Ok(url) = self.authorized_target.join(&raw_url) else {
534 continue;
535 };
536 let Ok(url) = self.discovery.canonicalize(&url) else {
537 continue;
538 };
539 snapshot.merge_visited(url);
540 }
541 snapshot
542 }
543
544 pub fn discovery_forms(&self) -> Vec<DiscoveryForm> {
549 self.discovery.snapshot().forms().iter().cloned().collect()
550 }
551
552 pub(crate) fn commit_discovery(
553 &self,
554 action_id: &str,
555 delta: DiscoveryDelta,
556 ) -> Result<(), ScannerError> {
557 let _bridge = self
558 .discovery_bridge
559 .lock()
560 .unwrap_or_else(std::sync::PoisonError::into_inner);
561 let snapshot = self.discovery.commit(action_id, delta)?;
562 for (url, parameters) in snapshot.endpoints() {
563 self.discovered_endpoints
564 .insert(url.clone(), parameters.iter().cloned().collect());
565 }
566 for url in snapshot.visited() {
567 self.visited_urls.insert(url.clone());
568 }
569 Ok(())
570 }
571
572 pub fn log(&self, msg: String) {
576 let _ = self.telemetry_tx.send(msg);
577 }
578
579 pub fn add_endpoint(&self, url: String, params: Vec<String>) {
583 let _bridge = self
584 .discovery_bridge
585 .lock()
586 .unwrap_or_else(std::sync::PoisonError::into_inner);
587 self.discovered_endpoints.insert(url, params);
588 }
589
590 pub fn mark_visited(&self, url: String) {
592 let _bridge = self
593 .discovery_bridge
594 .lock()
595 .unwrap_or_else(std::sync::PoisonError::into_inner);
596 self.visited_urls.insert(url);
597 }
598
599 pub fn is_visited(&self, url: &str) -> bool {
601 let _bridge = self
602 .discovery_bridge
603 .lock()
604 .unwrap_or_else(std::sync::PoisonError::into_inner);
605 self.visited_urls.contains(url)
606 }
607
608 pub fn endpoint_count(&self) -> usize {
610 let _bridge = self
611 .discovery_bridge
612 .lock()
613 .unwrap_or_else(std::sync::PoisonError::into_inner);
614 self.discovered_endpoints.len()
615 }
616
617 pub fn knowledge(&self) -> &KnowledgeBase {
622 &self.knowledge
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use std::time::Duration;
629
630 use termivar_core::{
631 ConfidenceScore, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
632 Hypothesis, KnowledgePredicate, Probability,
633 };
634
635 use super::*;
636 use crate::{
637 rules::{Expression, KnowledgeLayer},
638 verification::{ActiveVerifier, PassiveVerifier, VerificationCase, VerificationRule},
639 };
640
641 const TEST_ACTION_ID: &str = "legacy.observer.template_arithmetic";
642
643 struct ReportFixture {
644 report: VerificationReport,
645 hypothesis: Hypothesis,
646 evidence: Evidence,
647 }
648
649 type ReportIdentity<'a> = (&'a str, &'a str, &'a str);
650 type ReportBehavior = (VerificationStage, OutcomeStatus, bool);
651 type ReportScope<'a> = (bool, Option<&'a str>);
652
653 fn report_fixture(
654 knowledge: &KnowledgeBase,
655 subject: EntityId,
656 identity: ReportIdentity<'_>,
657 behavior: ReportBehavior,
658 ) -> ReportFixture {
659 report_fixture_with_scope(knowledge, subject, identity, behavior, (true, None))
660 }
661
662 fn report_fixture_with_scope(
663 knowledge: &KnowledgeBase,
664 subject: EntityId,
665 identity: ReportIdentity<'_>,
666 behavior: ReportBehavior,
667 scope: ReportScope<'_>,
668 ) -> ReportFixture {
669 let (action_id, source_component, suffix) = identity;
670 let (stage, status, knowledge_only) = behavior;
671 let (case_correlated_evidence, correlation_id) = scope;
672 let hypothesis_id = format!("hypothesis:legacy.bridge-test.{suffix}");
673 let case_id = format!("case:legacy.bridge-test.{suffix}");
674 let predicate = KnowledgePredicate::new("legacy.bridge-test", suffix).unwrap();
675 let hypothesis = Hypothesis::with_id(
676 hypothesis_id.clone(),
677 subject.clone(),
678 KnowledgePredicate::new("legacy.bridge-test", "audit-anchor").unwrap(),
679 EvidenceValue::Text("manual-review".to_owned()),
680 Probability::from_percent(50).unwrap(),
681 )
682 .unwrap();
683 knowledge.upsert_hypothesis(hypothesis.clone()).unwrap();
684 let baseline = knowledge.snapshot_for_subject(&subject);
685
686 let evidence = Evidence::with_id_at(
687 EvidenceId::parse(format!("evidence:legacy.bridge-test.{suffix}")).unwrap(),
688 subject.clone(),
689 EvidenceKind::Content,
690 predicate.clone(),
691 EvidenceValue::Boolean(true),
692 EvidenceSource::new(source_component, "bounded-review-observation")
693 .unwrap()
694 .with_correlation_id(correlation_id.unwrap_or(&case_id))
695 .unwrap(),
696 ConfidenceScore::from_percent(70).unwrap(),
697 0,
698 );
699 knowledge.insert_evidence(evidence.clone()).unwrap();
700 let after_probe = knowledge.snapshot_for_subject(&subject);
701
702 let case = VerificationCase::new(case_id, subject, action_id, hypothesis_id).unwrap();
703 let case = if knowledge_only {
704 case.without_hypothesis_transition()
705 } else {
706 case
707 };
708 let rule = VerificationRule::new(
709 format!("verify:legacy.bridge-test.{suffix}"),
710 stage,
711 100,
712 Expression::equals(
713 KnowledgeLayer::Evidence,
714 predicate,
715 EvidenceValue::Boolean(true),
716 ),
717 status,
718 Probability::from_percent(70).unwrap(),
719 "Bounded observation requires review",
720 )
721 .unwrap()
722 .scoped_to_action(action_id)
723 .unwrap();
724 let rule = if case_correlated_evidence {
725 rule.with_case_correlated_evidence().unwrap()
726 } else {
727 rule
728 };
729
730 let report = match stage {
731 VerificationStage::Active => {
732 let mut verifier = ActiveVerifier::new();
733 verifier.register(rule).unwrap();
734 verifier
735 .verify_snapshots(&case, &baseline, &after_probe)
736 .unwrap()
737 },
738 VerificationStage::Passive => {
739 let mut verifier = PassiveVerifier::new();
740 verifier.register(rule).unwrap();
741 verifier.verify_snapshot(&case, &after_probe).unwrap()
742 },
743 _ => panic!("unsupported test verification stage"),
744 };
745 ReportFixture {
746 report,
747 hypothesis,
748 evidence,
749 }
750 }
751
752 fn bridge_context(cancellation: CancellationToken) -> ScanContext {
753 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
754 ScanContext::with_cancellation(
755 Url::parse("https://example.test/").unwrap(),
756 Client::new(),
757 tx,
758 30,
759 cancellation,
760 )
761 }
762
763 #[tokio::test]
764 async fn test_scan_context_creation() {
765 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
766 let url = Url::parse("http://example.com").unwrap();
767 let client = Client::new();
768
769 let ctx = ScanContext::new(url, client, tx);
770 assert_eq!(
771 ctx.endpoint_count(),
772 1,
773 "the authorized root is always registered"
774 );
775 assert_eq!(ctx.knowledge().stats().evidence, 0);
776 }
777
778 #[test]
779 fn discovery_and_verification_envelopes_are_distinct() {
780 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
781 let target = Url::parse("https://example.test/").unwrap();
782 let discovery = DiscoveryLimits::new().with_max_requests(7);
783 let verification = VerificationLimits::new().with_max_requests(11).unwrap();
784
785 let context = ScanContext::new_with_discovery_limits(target, Client::new(), tx, discovery)
786 .with_pre_execution_verification_limits(verification);
787
788 assert_eq!(context.discovery_limits().max_requests(), 7);
789 assert_eq!(context.verification_limits().max_requests(), 11);
790 }
791
792 #[test]
793 fn public_target_replacement_cannot_retarget_built_in_authorities() {
794 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
795 let authorized = Url::parse("https://authorized.example.test/root").unwrap();
796 let replacement = Url::parse("https://replacement.example.test/").unwrap();
797 let mut context = ScanContext::new(authorized.clone(), Client::new(), tx);
798
799 context.target = replacement.clone();
800
801 assert_eq!(context.authorized_target(), &authorized);
802 assert_eq!(
803 context.legacy_verification_subject().unwrap().as_str(),
804 format!(
805 "authorized-origin:sha256:{}",
806 legacy_subject_digest("https://authorized.example.test")
807 )
808 );
809 assert!(context.canonicalize_discovery_url(&authorized).is_ok());
810 assert!(matches!(
811 context.canonicalize_discovery_url(&replacement),
812 Err(ScannerError::InvalidTarget)
813 ));
814 }
815
816 #[test]
817 fn public_cancellation_replacement_cannot_desynchronize_runtime_authority() {
818 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
819 let target = Url::parse("https://example.test/").unwrap();
820 let original = CancellationToken::new();
821 let mut context =
822 ScanContext::with_cancellation(target, Client::new(), tx, 1, original.clone());
823
824 context.cancel_token = CancellationToken::new();
825 original.cancel();
826
827 assert!(context.runtime_cancel_token().is_cancelled());
828 assert!(!context.cancel_token.is_cancelled());
829 }
830
831 #[test]
832 fn verifier_bridge_rejects_reports_not_backed_by_local_knowledge() {
833 let context = bridge_context(CancellationToken::new());
834 let foreign = KnowledgeBase::new();
835 let fixture = report_fixture(
836 &foreign,
837 context.legacy_verification_subject().unwrap(),
838 (TEST_ACTION_ID, TEST_ACTION_ID, "foreign"),
839 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
840 );
841
842 let error = context
843 .record_legacy_verification_reports(vec![fixture.report])
844 .unwrap_err();
845
846 assert!(matches!(
847 error,
848 ScannerError::InvalidLegacyVerificationReport
849 ));
850 assert_eq!(context.legacy_verification_checkpoint(), 0);
851 }
852
853 #[test]
854 fn verifier_bridge_rejects_mirrored_foreign_knowledge_authority() {
855 let context = bridge_context(CancellationToken::new());
856 let foreign = KnowledgeBase::new();
857 let fixture = report_fixture(
858 &foreign,
859 context.legacy_verification_subject().unwrap(),
860 (TEST_ACTION_ID, TEST_ACTION_ID, "mirrored-foreign"),
861 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
862 );
863
864 context
869 .knowledge()
870 .upsert_hypothesis(fixture.hypothesis)
871 .unwrap();
872 context
873 .knowledge()
874 .insert_evidence(fixture.evidence)
875 .unwrap();
876
877 let error = context
878 .record_legacy_verification_reports(vec![fixture.report])
879 .unwrap_err();
880
881 assert!(matches!(
882 error,
883 ScannerError::InvalidLegacyVerificationReport
884 ));
885 assert_eq!(context.legacy_verification_checkpoint(), 0);
886 }
887
888 #[test]
889 fn verifier_bridge_rejects_missing_local_evidence_or_hypothesis() {
890 let missing_evidence_context = bridge_context(CancellationToken::new());
891 let foreign = KnowledgeBase::new();
892 let missing_evidence = report_fixture(
893 &foreign,
894 missing_evidence_context
895 .legacy_verification_subject()
896 .unwrap(),
897 (TEST_ACTION_ID, TEST_ACTION_ID, "missing-evidence"),
898 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
899 );
900 missing_evidence_context
901 .knowledge()
902 .upsert_hypothesis(missing_evidence.hypothesis)
903 .unwrap();
904
905 let error = missing_evidence_context
906 .record_legacy_verification_reports(vec![missing_evidence.report])
907 .unwrap_err();
908 assert!(matches!(
909 error,
910 ScannerError::InvalidLegacyVerificationReport
911 ));
912 assert_eq!(missing_evidence_context.legacy_verification_checkpoint(), 0);
913
914 let missing_hypothesis_context = bridge_context(CancellationToken::new());
915 let foreign = KnowledgeBase::new();
916 let missing_hypothesis = report_fixture(
917 &foreign,
918 missing_hypothesis_context
919 .legacy_verification_subject()
920 .unwrap(),
921 (TEST_ACTION_ID, TEST_ACTION_ID, "missing-hypothesis"),
922 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
923 );
924 missing_hypothesis_context
925 .knowledge()
926 .insert_evidence(missing_hypothesis.evidence)
927 .unwrap();
928
929 let error = missing_hypothesis_context
930 .record_legacy_verification_reports(vec![missing_hypothesis.report])
931 .unwrap_err();
932 assert!(matches!(
933 error,
934 ScannerError::InvalidLegacyVerificationReport
935 ));
936 assert_eq!(
937 missing_hypothesis_context.legacy_verification_checkpoint(),
938 0
939 );
940 }
941
942 #[test]
943 fn verifier_bridge_rejects_transition_status_and_stage_broadening() {
944 let scenarios = [
945 (
946 VerificationStage::Active,
947 OutcomeStatus::NeedsReview,
948 false,
949 "transition-authorized",
950 ),
951 (
952 VerificationStage::Active,
953 OutcomeStatus::Success,
954 true,
955 "success",
956 ),
957 (
958 VerificationStage::Passive,
959 OutcomeStatus::NeedsReview,
960 true,
961 "passive",
962 ),
963 ];
964
965 for (stage, status, knowledge_only, suffix) in scenarios {
966 let context = bridge_context(CancellationToken::new());
967 let fixture = report_fixture(
968 context.knowledge(),
969 context.legacy_verification_subject().unwrap(),
970 (TEST_ACTION_ID, TEST_ACTION_ID, suffix),
971 (stage, status, knowledge_only),
972 );
973
974 let error = context
975 .record_legacy_verification_reports(vec![fixture.report])
976 .unwrap_err();
977 assert!(matches!(
978 error,
979 ScannerError::InvalidLegacyVerificationReport
980 ));
981 assert_eq!(context.legacy_verification_checkpoint(), 0, "{suffix}");
982 }
983 }
984
985 #[test]
986 fn verifier_bridge_rejects_wrong_subject_action_and_producer() {
987 let wrong_subject_context = bridge_context(CancellationToken::new());
988 let wrong_subject = report_fixture(
989 wrong_subject_context.knowledge(),
990 EntityId::new("authorized-origin:https://other.test").unwrap(),
991 (TEST_ACTION_ID, TEST_ACTION_ID, "wrong-subject"),
992 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
993 );
994 assert!(matches!(
995 wrong_subject_context.record_legacy_verification_reports(vec![wrong_subject.report]),
996 Err(ScannerError::InvalidLegacyVerificationReport)
997 ));
998
999 let wrong_action_context = bridge_context(CancellationToken::new());
1000 let wrong_action = report_fixture(
1001 wrong_action_context.knowledge(),
1002 wrong_action_context.legacy_verification_subject().unwrap(),
1003 (
1004 "legacy.observer.unsupported",
1005 "legacy.observer.unsupported",
1006 "wrong-action",
1007 ),
1008 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
1009 );
1010 assert!(matches!(
1011 wrong_action_context.record_legacy_verification_reports(vec![wrong_action.report]),
1012 Err(ScannerError::InvalidLegacyVerificationReport)
1013 ));
1014
1015 let wrong_producer_context = bridge_context(CancellationToken::new());
1016 let wrong_producer = report_fixture(
1017 wrong_producer_context.knowledge(),
1018 wrong_producer_context
1019 .legacy_verification_subject()
1020 .unwrap(),
1021 (
1022 TEST_ACTION_ID,
1023 "legacy.observer.different-producer",
1024 "wrong-producer",
1025 ),
1026 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
1027 );
1028 assert!(matches!(
1029 wrong_producer_context.record_legacy_verification_reports(vec![wrong_producer.report]),
1030 Err(ScannerError::InvalidLegacyVerificationReport)
1031 ));
1032
1033 assert_eq!(wrong_subject_context.legacy_verification_checkpoint(), 0);
1034 assert_eq!(wrong_action_context.legacy_verification_checkpoint(), 0);
1035 assert_eq!(wrong_producer_context.legacy_verification_checkpoint(), 0);
1036 }
1037
1038 #[test]
1039 fn verifier_bridge_rejects_evidence_from_another_case() {
1040 let context = bridge_context(CancellationToken::new());
1041 let fixture = report_fixture_with_scope(
1042 context.knowledge(),
1043 context.legacy_verification_subject().unwrap(),
1044 (TEST_ACTION_ID, TEST_ACTION_ID, "wrong-correlation"),
1045 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
1046 (false, Some("case:legacy.bridge-test.another-case")),
1047 );
1048 assert_eq!(
1049 fixture.report.outcome().status(),
1050 OutcomeStatus::NeedsReview
1051 );
1052
1053 assert!(matches!(
1054 context.record_legacy_verification_reports(vec![fixture.report]),
1055 Err(ScannerError::InvalidLegacyVerificationReport)
1056 ));
1057 assert_eq!(context.legacy_verification_checkpoint(), 0);
1058 }
1059
1060 #[test]
1061 fn duplicate_verifier_report_batch_is_rejected_atomically() {
1062 let context = bridge_context(CancellationToken::new());
1063 let fixture = report_fixture(
1064 context.knowledge(),
1065 context.legacy_verification_subject().unwrap(),
1066 (LEGACY_SQL_ACTION_ID, LEGACY_SQL_ACTION_ID, "duplicate"),
1067 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
1068 );
1069
1070 let error = context
1071 .record_legacy_verification_reports(vec![fixture.report.clone(), fixture.report])
1072 .unwrap_err();
1073
1074 assert!(matches!(
1075 error,
1076 ScannerError::InvalidLegacyVerificationReport
1077 ));
1078 assert_eq!(context.legacy_verification_checkpoint(), 0);
1079 }
1080
1081 #[test]
1082 fn verifier_report_replay_cannot_mutate_an_accepted_ledger() {
1083 let context = bridge_context(CancellationToken::new());
1084 let fixture = report_fixture(
1085 context.knowledge(),
1086 context.legacy_verification_subject().unwrap(),
1087 (TEST_ACTION_ID, TEST_ACTION_ID, "replay"),
1088 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
1089 );
1090
1091 context
1092 .record_legacy_verification_reports(vec![fixture.report.clone()])
1093 .unwrap();
1094 let accepted = context.legacy_verification_outcomes_since(0);
1095 let error = context
1096 .record_legacy_verification_reports(vec![fixture.report])
1097 .unwrap_err();
1098
1099 assert!(matches!(
1100 error,
1101 ScannerError::InvalidLegacyVerificationReport
1102 ));
1103 assert_eq!(context.legacy_verification_outcomes_since(0), accepted);
1104 }
1105
1106 #[test]
1107 fn cancellation_before_verifier_commit_leaves_ledger_empty() {
1108 let cancellation = CancellationToken::new();
1109 let context = bridge_context(cancellation.clone());
1110 let fixture = report_fixture(
1111 context.knowledge(),
1112 context.legacy_verification_subject().unwrap(),
1113 (TEST_ACTION_ID, TEST_ACTION_ID, "cancelled"),
1114 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
1115 );
1116 cancellation.cancel();
1117
1118 let error = context
1119 .record_legacy_verification_reports(vec![fixture.report])
1120 .unwrap_err();
1121
1122 assert!(matches!(error, ScannerError::Cancelled));
1123 assert_eq!(context.legacy_verification_checkpoint(), 0);
1124 }
1125
1126 #[test]
1127 fn expired_verification_deadline_leaves_ledger_empty() {
1128 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1129 let limits = VerificationLimits::new()
1130 .with_max_wall_time(Duration::from_millis(1))
1131 .unwrap();
1132 let context = ScanContext::new_with_verification_limits(
1133 Url::parse("https://example.test/").unwrap(),
1134 Client::new(),
1135 tx,
1136 limits,
1137 );
1138 let fixture = report_fixture(
1139 context.knowledge(),
1140 context.legacy_verification_subject().unwrap(),
1141 (TEST_ACTION_ID, TEST_ACTION_ID, "deadline"),
1142 (VerificationStage::Active, OutcomeStatus::NeedsReview, true),
1143 );
1144 context
1145 .ensure_legacy_verification_commit(TEST_ACTION_ID)
1146 .unwrap();
1147 std::thread::sleep(Duration::from_millis(5));
1148
1149 let error = context
1150 .record_legacy_verification_reports(vec![fixture.report])
1151 .unwrap_err();
1152
1153 assert!(matches!(error, ScannerError::BudgetExceeded(_)));
1154 assert_eq!(context.legacy_verification_checkpoint(), 0);
1155 }
1156
1157 #[tokio::test]
1158 async fn test_add_endpoint_zero_copy() {
1159 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1160 let url = Url::parse("http://example.com").unwrap();
1161 let client = Client::new();
1162 let ctx = ScanContext::new(url, client, tx);
1163
1164 ctx.add_endpoint(
1165 "/api/users".to_string(),
1166 vec!["id".to_string(), "email".to_string()],
1167 );
1168 assert_eq!(ctx.endpoint_count(), 2);
1169
1170 let endpoints = ctx.discovered_endpoints.clone();
1171 assert!(endpoints.contains_key("/api/users"));
1172 let snapshot = ctx.discovery_snapshot();
1173 let canonical = "http://example.com/api/users";
1174 assert_eq!(
1175 snapshot.endpoints()[canonical],
1176 std::collections::BTreeSet::from(["email".to_owned(), "id".to_owned()]),
1177 "relative public host seeds remain visible to migrated phases"
1178 );
1179 }
1180
1181 #[tokio::test]
1182 async fn public_endpoint_seed_derives_existing_query_names() {
1183 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1184 let target = Url::parse("http://example.com/").unwrap();
1185 let ctx = ScanContext::new(target, Client::new(), tx);
1186
1187 ctx.add_endpoint("/search?q=known&mode=safe".to_owned(), Vec::new());
1188
1189 let snapshot = ctx.discovery_snapshot();
1190 assert_eq!(
1191 snapshot.endpoints()["http://example.com/search?mode=safe&q=known"],
1192 std::collections::BTreeSet::from(["mode".to_owned(), "q".to_owned()])
1193 );
1194 }
1195
1196 #[tokio::test]
1197 async fn test_visited_urls_concurrent() {
1198 let (tx, _) = tokio::sync::mpsc::unbounded_channel();
1199 let url = Url::parse("http://example.com").unwrap();
1200 let client = Client::new();
1201 let ctx = ScanContext::new(url, client, tx);
1202
1203 ctx.mark_visited("http://example.com/page1".to_string());
1204 assert!(ctx.is_visited("http://example.com/page1"));
1205 assert!(!ctx.is_visited("http://example.com/page2"));
1206 }
1207}