Skip to main content

termivar_scanner/
context.rs

1//! Scan context: shared state for the historical `termivar legacy-scan` pipeline.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** non-default `legacy-scanner` feature.
6//! - **Execution:** Surface A — `ScanContext` owns the shared scan state and a
7//!   private `KnowledgeBase`. Corrected built-in verification phases may record
8//!   bounded evidence and knowledge-only `NeedsReview` outcomes through the
9//!   verifier-owned bridge; arbitrary legacy strings never gain that authority.
10//! - **Default `termivar scan`:** no.
11//! - **Support:** legacy alpha.
12//!
13//! See `docs/internals/runtime-map.md`.
14
15use 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/// Zero-copy shared state across all scan phases.
58///
59/// Construct contexts through [`ScanContext::new`] or one of the policy-aware
60/// constructors. Additional runtime state may be introduced without requiring
61/// extension authors to initialize internal fields.
62///
63/// # Examples
64///
65/// ```rust
66/// use reqwest::Client;
67/// use url::Url;
68/// use termivar_scanner::ScanContext;
69///
70/// let (telemetry_tx, _telemetry_rx) = tokio::sync::mpsc::unbounded_channel();
71/// let context = ScanContext::new(
72///     Url::parse("https://example.test")?,
73///     Client::new(),
74///     telemetry_tx,
75/// );
76///
77/// assert_eq!(context.knowledge().stats().evidence, 0);
78/// # Ok::<(), Box<dyn std::error::Error>>(())
79/// ```
80///
81/// Direct construction is intentionally unsupported so new runtime state does
82/// not break extension code:
83///
84/// ```compile_fail
85/// use reqwest::Client;
86/// use url::Url;
87/// use termivar_scanner::ScanContext;
88///
89/// let (telemetry_tx, _telemetry_rx) = tokio::sync::mpsc::unbounded_channel();
90/// let context = ScanContext::new(
91///     Url::parse("https://example.test").unwrap(),
92///     Client::new(),
93///     telemetry_tx,
94/// );
95/// let _modified = ScanContext {
96///     phase_timeout_secs: 30,
97///     ..context
98/// };
99/// ```
100#[derive(Clone)]
101#[non_exhaustive]
102pub struct ScanContext {
103    /// Compatibility view of the root URL whose scope is being scanned.
104    ///
105    /// Mutating this pre-1.0 public field does not change the immutable target
106    /// captured by built-in authorities or the runner's provenance envelope.
107    pub target: Url,
108    /// Raw HTTP client retained for phase one and custom phases that explicitly
109    /// accept the legacy direct-I/O contract.
110    ///
111    /// Built-in phases two through nine ignore this client. Discovery and
112    /// verification use distinct private exact-origin bounded authorities.
113    pub client: Arc<Client>,
114    /// Discovered endpoints mapped to their observed parameter names.
115    pub discovered_endpoints: Arc<DashMap<String, Vec<String>>>,
116    /// URLs already visited by discovery phases.
117    pub visited_urls: Arc<DashSet<String>>,
118    /// Asynchronous telemetry channel for logging and analysis.
119    pub telemetry_tx: tokio::sync::mpsc::UnboundedSender<String>,
120    /// Structured logger shared by scan phases.
121    pub logger: Arc<Logger>,
122    /// Maximum runtime of an individual phase, in seconds.
123    pub phase_timeout_secs: u64,
124    /// Token used to propagate graceful scan cancellation.
125    ///
126    /// Calling [`CancellationToken::cancel`] affects the immutable shared token
127    /// captured by the runner and built-in authorities. Replacing this public
128    /// compatibility field does not replace that runtime authority.
129    pub cancel_token: CancellationToken,
130    /// Event bus used to publish scan lifecycle and progress events.
131    pub event_bus: Arc<EventBus>,
132    // Evidence-driven memory shared by discovery, reasoning, and execution phases.
133    // Kept private so its construction and replacement remain runtime policy.
134    knowledge: KnowledgeBase,
135    // Immutable target/provenance authority captured at construction. The
136    // public compatibility field cannot retarget built-in transport or reports.
137    authorized_target: Url,
138    // Immutable clone of the host-supplied cancellation authority. Calling
139    // cancel through any clone remains effective; replacing the public field
140    // cannot desynchronize built-ins from the runner.
141    runtime_cancel_token: CancellationToken,
142    // Shared, redirect-disabled authority for legacy discovery phases 2–4.
143    discovery: LegacyDiscoveryAuthority,
144    // Distinct active authority shared by legacy verification phases 5–9.
145    verification: LegacyVerificationAuthority,
146    // Serializes typed discovery commits with their public compatibility-map
147    // projection so internal consumers observe an old or complete new batch.
148    discovery_bridge: Arc<Mutex<()>>,
149    // Verifier-owned, knowledge-only outcomes accepted from corrected built-in
150    // legacy phases. The runner checkpoints this ledger per phase so an error,
151    // timeout, panic, or cancellation cannot publish a partial claim batch.
152    legacy_verification_outcomes: Arc<Mutex<Vec<RunOutcomeRecord>>>,
153}
154
155impl ScanContext {
156    /// Creates a context with a five-minute phase timeout, a fresh cancellation
157    /// token, and a fresh event bus.
158    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) // 5 min default
164    }
165
166    /// Creates a context with a host-selected finite discovery envelope and
167    /// otherwise default runtime services.
168    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    /// Creates a context with a host-selected finite verification envelope and
179    /// otherwise default runtime services.
180    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    /// Creates a context with an explicit per-phase timeout in seconds.
191    ///
192    /// A fresh cancellation token and event bus are installed for the scan.
193    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    /// Creates a context with explicit timeout and cancellation policy.
209    ///
210    /// A fresh event bus is installed for the scan.
211    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    /// Creates a context with all externally configurable runtime services.
229    ///
230    /// The authorized root endpoint is registered immediately; the visited
231    /// set, typed forms, logger, and knowledge base start empty. The supplied
232    /// raw HTTP client is promoted into shared ownership for unmigrated and
233    /// custom legacy phases.
234    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        // Crate-owned composition calls this only before the context is shared
278        // or any discovery request can consume authority. Keeping this seam
279        // private prevents hosts from multiplying a live budget or discarding
280        // committed typed state through mid-run reconfiguration.
281        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    /// Returns the configured discovery envelope.
295    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        // As with discovery policy, crate-owned composition calls this only
304        // before execution. A live authority can never be reset to multiply
305        // its request or byte budget.
306        self.verification = LegacyVerificationAuthority::new(
307            &self.authorized_target,
308            limits,
309            self.runtime_cancel_token.clone(),
310        );
311        self
312    }
313
314    /// Returns the finite envelope shared by built-in legacy verification
315    /// phases five through nine.
316    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    /// Returns the non-secret subject shared by legacy verification records.
358    ///
359    /// The typed report already carries the exact authorized origin. Evidence
360    /// identity deliberately omits paths, query names, parameter names, and
361    /// unkeyed digests of those low-entropy values.
362    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    /// Atomically accepts verifier-produced manual-review outcomes from one
372    /// corrected built-in legacy phase.
373    ///
374    /// The bridge intentionally permits only active, knowledge-only
375    /// `NeedsReview` reports. It cannot mint success, confirmation, negative
376    /// claims, or hypothesis transitions. Each report is first validated
377    /// against the same knowledge snapshot from which its verifier evaluated
378    /// evidence.
379    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        // Preserve the pre-1.0 host seeding contract while phases migrate to
502        // typed state. Invalid or out-of-scope legacy strings are never
503        // promoted into the bounded authority snapshot. Capture compatibility
504        // mirrors first and clone typed state last: internal commits publish
505        // typed state before updating those mirrors, so this ordering can
506        // observe only the old or complete new typed batch, never a strict
507        // partial internal commit.
508        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    /// Returns stable typed form observations from bounded discovery.
545    ///
546    /// Ownership is parser-tree-descendant based; malformed HTML form-owner
547    /// associations are not inferred.
548    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    /// Sends a plain-text message to the telemetry channel.
573    ///
574    /// Messages are dropped when the receiving side has closed.
575    pub fn log(&self, msg: String) {
576        let _ = self.telemetry_tx.send(msg);
577    }
578
579    /// Records a discovered endpoint and its observed parameter names.
580    ///
581    /// Recording the same URL again replaces its prior parameter list.
582    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    /// Marks a URL as visited for duplicate-scan prevention.
591    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    /// Returns whether a URL has already been marked as visited.
600    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    /// Returns the number of distinct discovered endpoint URLs.
609    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    /// Returns the evidence-driven knowledge base shared by this scan.
618    ///
619    /// The context retains ownership so the runtime can preserve one knowledge
620    /// identity across phases and cloned contexts.
621    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        // Reproduce the exact records and write order locally so subjects,
865        // identifiers, values, and revision counters all match. The report
866        // must still remain bound to the foreign in-memory authority that
867        // evaluated it.
868        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}