Skip to main content

termivar_scanner/
runner.rs

1//! Typed orchestration for the opt-in historical phase scanner.
2//!
3//! `ScanRunner` polls each legacy phase as a structurally owned future so
4//! cancellation, timeout, and panic boundaries are observable. Its public result is a
5//! fail-closed [`RunReport`]: raw legacy findings are retained only as
6//! zero-confidence informational `unknown` records, while corrected built-in
7//! phases may project existing verifier-owned, knowledge-only `NeedsReview`
8//! outcomes. Phase-one/custom direct-I/O accounting remains unmetered; elapsed
9//! wall time is observed.
10
11use std::{
12    panic::AssertUnwindSafe,
13    sync::Arc,
14    time::{Duration, Instant},
15};
16
17use chrono::Utc;
18use futures::FutureExt;
19use sha2::{Digest, Sha256};
20use termivar_core::{
21    EntityId, ResourceAccounting, RunAccounting, RunOutcomeRecord, RunReport, RunReportError,
22    RunReportInput, RunStatus, RunStepReport, RunStepStatus, RunStopCode, RunStopReason,
23    ScanFinding, MAX_RUN_REPORT_STEPS,
24};
25
26use crate::{
27    context::{legacy_subject_digest, ScanContext},
28    contracts::ScanPhase,
29    error::ScannerError,
30    event_bus::{Event, EventType},
31};
32
33const LEGACY_UNKNOWN_RATIONALE: &str =
34    "Legacy heuristic output has not passed verifier policy; its disposition is unknown.";
35const LEGACY_REDACTED_SUMMARY: &str =
36    "Legacy phase evidence is withheld at the typed report boundary.";
37
38/// Orchestrates historical scan phases in deterministic phase-number order.
39pub struct ScanRunner {
40    phases: Vec<Arc<dyn ScanPhase>>,
41}
42
43#[derive(Debug)]
44enum PhaseExecution {
45    Succeeded(Vec<ScanFinding>),
46    Failed,
47    Panicked,
48    JoinFailed,
49    TimedOut,
50    Cancelled,
51    BudgetExhausted,
52}
53
54impl ScanRunner {
55    /// Creates a new empty runner.
56    pub fn new() -> Self {
57        Self { phases: Vec::new() }
58    }
59
60    /// Registers a phase and preserves stable phase-number ordering.
61    pub fn register_phase(&mut self, phase: Box<dyn ScanPhase>) {
62        self.phases.push(Arc::from(phase));
63        self.phases
64            .sort_by_key(|phase| (phase.phase_number(), phase.name()));
65    }
66
67    /// Executes every registered phase and returns a typed, serializable report.
68    ///
69    /// Errors and timeouts are isolated to their phase and make the report
70    /// partial (or failed when no phase succeeds). Host cancellation stops the
71    /// active phase future and marks later phases skipped. Dropping this future
72    /// also drops the active phase future instead of detaching it.
73    pub async fn run_pipeline(&self, context: ScanContext) -> Result<RunReport, RunReportError> {
74        if !context.runtime_authority_is_intact() {
75            return Err(RunReportError::RunAuthorityMismatch);
76        }
77        if self.phases.len() > MAX_RUN_REPORT_STEPS {
78            return Err(RunReportError::TooMany {
79                field: "registered phases",
80                actual: self.phases.len(),
81                limit: MAX_RUN_REPORT_STEPS,
82            });
83        }
84        if self.phases.windows(2).any(|pair| {
85            pair[0].phase_number() == pair[1].phase_number() && pair[0].name() == pair[1].name()
86        }) {
87            return Err(RunReportError::DuplicateStepIdentity);
88        }
89        let run_started_at = Utc::now();
90        let run_started = Instant::now();
91        let context = Arc::new(context);
92        let target = redacted_target(context.authorized_target());
93        let authorized_origin = authorized_origin(context.authorized_target());
94        let subject = subject_for_origin(&authorized_origin)?;
95        // Validate every externally derived envelope field before a phase can
96        // publish an event, perform I/O, or mutate shared state. The final
97        // report construction cannot then discover a target/origin bound error
98        // only after those effects occurred.
99        let _validated_envelope = RunReportInput::new(
100            RunStatus::Complete,
101            RunStopReason::new(
102                RunStopCode::NoEligibleAction,
103                "Run envelope validated before legacy phase execution.",
104            )?,
105            target.clone(),
106            authorized_origin.clone(),
107            run_started_at,
108            run_started_at,
109        )?;
110        let mut steps = Vec::with_capacity(self.phases.len());
111        let mut outcomes = Vec::new();
112        let mut successful_steps = 0_usize;
113        let mut failed_steps = 0_usize;
114        let mut saw_timeout = false;
115        let mut saw_join_failure = false;
116        let mut saw_budget_exhaustion = false;
117        let mut cancelled = context.runtime_cancel_token().is_cancelled();
118
119        if cancelled {
120            append_skipped_steps(
121                &mut steps,
122                &self.phases,
123                "Host cancellation prevented this legacy phase from starting.",
124            )?;
125        }
126
127        for (index, phase) in self.phases.iter().enumerate() {
128            if cancelled {
129                break;
130            }
131            if context.runtime_cancel_token().is_cancelled() {
132                append_skipped_steps(
133                    &mut steps,
134                    &self.phases[index..],
135                    "Host cancellation prevented this legacy phase from starting.",
136                )?;
137                cancelled = true;
138                break;
139            }
140
141            let phase_number = phase.phase_number();
142            let step_ordinal = step_sequence(&steps)?;
143            let action_id = phase_action_id(phase_number, phase.name(), step_ordinal);
144            let started = Instant::now();
145            let verification_checkpoint = context.legacy_verification_checkpoint();
146            publish_started(&context, phase_number, &action_id);
147            context.log(format!("legacy phase started: {action_id}"));
148
149            let execution = execute_phase(
150                Arc::clone(phase),
151                Arc::clone(&context),
152                Duration::from_secs(context.phase_timeout_secs),
153            )
154            .await;
155            let duration_ms = duration_ms(started.elapsed());
156
157            let (status, rationale) = match execution {
158                PhaseExecution::Succeeded(findings) => {
159                    successful_steps = successful_steps.saturating_add(1);
160                    let verified_outcomes =
161                        context.legacy_verification_outcomes_since(verification_checkpoint);
162                    let has_verified_outcomes = !verified_outcomes.is_empty();
163                    outcomes.extend(verified_outcomes);
164                    if !has_verified_outcomes && !findings.is_empty() {
165                        outcomes.push(outcome_from_legacy_findings(subject.clone(), &action_id)?);
166                    }
167                    (RunStepStatus::Succeeded, "Legacy phase returned normally.")
168                },
169                PhaseExecution::Failed => {
170                    context.rollback_legacy_verification_outcomes(verification_checkpoint);
171                    failed_steps = failed_steps.saturating_add(1);
172                    (
173                        RunStepStatus::Failed,
174                        "Legacy phase returned an error; no result was accepted.",
175                    )
176                },
177                PhaseExecution::Panicked | PhaseExecution::JoinFailed => {
178                    context.rollback_legacy_verification_outcomes(verification_checkpoint);
179                    failed_steps = failed_steps.saturating_add(1);
180                    saw_join_failure = true;
181                    (
182                        RunStepStatus::Failed,
183                        "Legacy phase execution did not complete normally; no result was accepted.",
184                    )
185                },
186                PhaseExecution::TimedOut => {
187                    context.rollback_legacy_verification_outcomes(verification_checkpoint);
188                    failed_steps = failed_steps.saturating_add(1);
189                    saw_timeout = true;
190                    (
191                        RunStepStatus::TimedOut,
192                        "Legacy phase exceeded its deadline and was stopped.",
193                    )
194                },
195                PhaseExecution::Cancelled => {
196                    context.rollback_legacy_verification_outcomes(verification_checkpoint);
197                    cancelled = true;
198                    (
199                        RunStepStatus::Cancelled,
200                        "Host cancellation stopped the active legacy phase.",
201                    )
202                },
203                PhaseExecution::BudgetExhausted => {
204                    context.rollback_legacy_verification_outcomes(verification_checkpoint);
205                    failed_steps = failed_steps.saturating_add(1);
206                    saw_budget_exhaustion = true;
207                    (
208                        RunStepStatus::BudgetExhausted,
209                        "A bounded legacy transport resource limit stopped this phase.",
210                    )
211                },
212            };
213
214            steps.push(RunStepReport::new(
215                step_ordinal,
216                action_id.clone(),
217                status,
218                duration_ms,
219                Some(rationale.to_string()),
220            )?);
221            publish_finished(&context, phase_number, &action_id, status);
222            context.log(format!(
223                "legacy phase finished: {action_id} status={}",
224                step_status_name(status)
225            ));
226
227            if cancelled {
228                append_skipped_steps(
229                    &mut steps,
230                    &self.phases[index + 1..],
231                    "Host cancellation prevented this legacy phase from starting.",
232                )?;
233                break;
234            }
235            if status == RunStepStatus::BudgetExhausted {
236                append_skipped_steps(
237                    &mut steps,
238                    &self.phases[index + 1..],
239                    "Bounded legacy transport budget exhaustion prevented this dependent phase from starting.",
240                )?;
241                break;
242            }
243        }
244
245        let (status, stop_code, stop_detail) = if cancelled {
246            classify_run(
247                self.phases.is_empty(),
248                successful_steps,
249                failed_steps,
250                saw_timeout,
251                saw_join_failure,
252                saw_budget_exhaustion,
253                true,
254            )
255        } else {
256            classify_run(
257                self.phases.is_empty(),
258                successful_steps,
259                failed_steps,
260                saw_timeout,
261                saw_join_failure,
262                saw_budget_exhaustion,
263                cancelled,
264            )
265        };
266        let run_completed_at = Utc::now().max(run_started_at);
267        let report_input = RunReportInput::new(
268            status,
269            RunStopReason::new(stop_code, stop_detail)?,
270            target,
271            authorized_origin,
272            run_started_at,
273            run_completed_at,
274        )?
275        .with_accounting(RunAccounting::new(
276            ResourceAccounting::unmetered(),
277            ResourceAccounting::unmetered(),
278            ResourceAccounting::unmetered(),
279            ResourceAccounting::observed(duration_ms(run_started.elapsed())),
280        ))
281        .with_steps(steps)
282        .with_outcomes(outcomes);
283        RunReport::new(report_input)
284    }
285}
286
287impl Default for ScanRunner {
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293async fn execute_phase(
294    phase: Arc<dyn ScanPhase>,
295    context: Arc<ScanContext>,
296    phase_timeout: Duration,
297) -> PhaseExecution {
298    if phase_timeout.is_zero() {
299        return PhaseExecution::TimedOut;
300    }
301    let cancellation = context.runtime_cancel_token();
302    let execution = AssertUnwindSafe(phase.execute(&context)).catch_unwind();
303    tokio::pin!(execution);
304
305    tokio::select! {
306        biased;
307        _ = cancellation.cancelled() => PhaseExecution::Cancelled,
308        completed = &mut execution => classify_phase_result(completed),
309        _ = tokio::time::sleep(phase_timeout) => PhaseExecution::TimedOut,
310    }
311}
312
313fn classify_phase_result(
314    completed: std::thread::Result<crate::Result<Vec<ScanFinding>>>,
315) -> PhaseExecution {
316    match completed {
317        Ok(Ok(findings)) => PhaseExecution::Succeeded(findings),
318        Ok(Err(ScannerError::TaskJoinFailed)) => PhaseExecution::JoinFailed,
319        Ok(Err(ScannerError::Cancelled)) => PhaseExecution::Cancelled,
320        Ok(Err(ScannerError::BudgetExceeded(_))) => PhaseExecution::BudgetExhausted,
321        Ok(Err(_)) => PhaseExecution::Failed,
322        Err(_) => PhaseExecution::Panicked,
323    }
324}
325
326/// Collects a structurally owned worker set without accepting partial output.
327///
328/// Any join failure aborts and drains every remaining worker before returning a
329/// constant-detail error. The original [`tokio::task::JoinError`] is intentionally dropped:
330/// it can contain a target-controlled panic payload.
331#[cfg(test)]
332pub(crate) async fn collect_join_set<T: 'static>(
333    tasks: &mut tokio::task::JoinSet<T>,
334) -> crate::Result<Vec<T>> {
335    let mut completed = Vec::with_capacity(tasks.len());
336    while let Some(joined) = tasks.join_next().await {
337        match joined {
338            Ok(value) => completed.push(value),
339            Err(_) => {
340                tasks.shutdown().await;
341                return Err(ScannerError::TaskJoinFailed);
342            },
343        }
344    }
345    Ok(completed)
346}
347
348fn append_skipped_steps(
349    steps: &mut Vec<RunStepReport>,
350    phases: &[Arc<dyn ScanPhase>],
351    rationale: &str,
352) -> Result<(), RunReportError> {
353    for phase in phases {
354        let ordinal = step_sequence(steps)?;
355        steps.push(RunStepReport::new(
356            ordinal,
357            phase_action_id(phase.phase_number(), phase.name(), ordinal),
358            RunStepStatus::Skipped,
359            0,
360            Some(rationale.to_string()),
361        )?);
362    }
363    Ok(())
364}
365
366fn step_sequence(steps: &[RunStepReport]) -> Result<u32, RunReportError> {
367    u32::try_from(steps.len() + 1).map_err(|_| RunReportError::TooMany {
368        field: "run steps",
369        actual: steps.len() + 1,
370        limit: u32::MAX as usize,
371    })
372}
373
374fn classify_run(
375    no_phases: bool,
376    successful_steps: usize,
377    failed_steps: usize,
378    saw_timeout: bool,
379    saw_join_failure: bool,
380    saw_budget_exhaustion: bool,
381    cancelled: bool,
382) -> (RunStatus, RunStopCode, &'static str) {
383    if cancelled {
384        return (
385            RunStatus::Cancelled,
386            RunStopCode::Cancelled,
387            "Host cancellation stopped the legacy run.",
388        );
389    }
390    if no_phases {
391        return (
392            RunStatus::Complete,
393            RunStopCode::NoEligibleAction,
394            "No legacy phases were registered.",
395        );
396    }
397    if failed_steps == 0 {
398        return (
399            RunStatus::Complete,
400            RunStopCode::Completed,
401            "Every registered legacy phase returned normally.",
402        );
403    }
404    if successful_steps > 0 {
405        return (
406            RunStatus::Partial,
407            if saw_join_failure {
408                RunStopCode::TaskJoinFailed
409            } else if saw_budget_exhaustion {
410                RunStopCode::BudgetExhausted
411            } else if saw_timeout {
412                RunStopCode::StepTimedOut
413            } else {
414                RunStopCode::StepFailed
415            },
416            "One or more legacy phases did not complete; successful phase output is partial.",
417        );
418    }
419    if saw_join_failure {
420        (
421            RunStatus::Failed,
422            RunStopCode::TaskJoinFailed,
423            "No legacy phase completed successfully; at least one task failed to join.",
424        )
425    } else if saw_budget_exhaustion {
426        (
427            RunStatus::Failed,
428            RunStopCode::BudgetExhausted,
429            "No legacy phase completed successfully; a bounded transport budget was exhausted.",
430        )
431    } else if saw_timeout {
432        (
433            RunStatus::Failed,
434            RunStopCode::StepTimedOut,
435            "No legacy phase completed successfully; at least one phase timed out.",
436        )
437    } else {
438        (
439            RunStatus::Failed,
440            RunStopCode::StepFailed,
441            "No legacy phase completed successfully.",
442        )
443    }
444}
445
446fn outcome_from_legacy_findings(
447    subject: EntityId,
448    phase_action_id: &str,
449) -> Result<RunOutcomeRecord, RunReportError> {
450    let action_id = format!("{phase_action_id}.observations");
451    RunOutcomeRecord::unresolved(
452        subject,
453        action_id,
454        LEGACY_UNKNOWN_RATIONALE,
455        LEGACY_REDACTED_SUMMARY,
456    )
457}
458
459fn phase_action_id(phase: u8, name: &str, ordinal: u32) -> String {
460    format!("{}.step.{ordinal}", stable_action_id("phase", phase, name))
461}
462
463fn stable_action_id(kind: &str, phase: u8, value: &str) -> String {
464    let mut slug = String::with_capacity(32);
465    for character in value.chars() {
466        let character = character.to_ascii_lowercase();
467        if character.is_ascii_alphanumeric()
468            || (matches!(character, '-' | '_' | '.') && !slug.is_empty())
469        {
470            slug.push(character);
471        }
472        if slug.len() >= 32 {
473            break;
474        }
475    }
476    let slug = slug.trim_matches(['-', '_', '.']);
477    let slug = if slug.is_empty() { "unnamed" } else { slug };
478    let digest = sha256_hex(&[value.as_bytes()]);
479    format!("legacy.{kind}.{phase}.{slug}.{}", &digest[..12])
480}
481
482fn redacted_target(url: &url::Url) -> String {
483    match serialized_authority(url) {
484        Some(authority) if url.path() == "/" => format!("{authority}/"),
485        Some(authority) => format!("{authority}/[path-redacted]"),
486        None => format!("{}:[target-redacted]", url.scheme()),
487    }
488}
489
490fn authorized_origin(url: &url::Url) -> String {
491    serialized_authority(url).unwrap_or_else(|| format!("{}:[origin-unavailable]", url.scheme()))
492}
493
494fn serialized_authority(url: &url::Url) -> Option<String> {
495    let host = url.host_str()?;
496    let host = if host.starts_with('[') && host.ends_with(']') {
497        host.to_string()
498    } else if host.contains(':') {
499        format!("[{host}]")
500    } else {
501        host.to_string()
502    };
503    let port = url
504        .port()
505        .map_or_else(String::new, |port| format!(":{port}"));
506    Some(format!("{}://{host}{port}", url.scheme()))
507}
508
509fn subject_for_origin(origin: &str) -> Result<EntityId, RunReportError> {
510    EntityId::new(format!(
511        "authorized-origin:sha256:{}",
512        legacy_subject_digest(origin)
513    ))
514    .map_err(|_| RunReportError::Blank { field: "subject" })
515}
516
517fn sha256_hex(parts: &[&[u8]]) -> String {
518    let mut digest = Sha256::new();
519    for part in parts {
520        digest.update((part.len() as u64).to_be_bytes());
521        digest.update(part);
522    }
523    format!("{:x}", digest.finalize())
524}
525
526fn duration_ms(duration: Duration) -> u64 {
527    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
528}
529
530fn publish_started(context: &ScanContext, phase: u8, action_id: &str) {
531    context.event_bus.publish(
532        Event::builder(EventType::PhaseStarted, action_id)
533            .data("phase_number", phase.to_string())
534            .data("action_id", action_id)
535            .build(),
536    );
537}
538
539fn publish_finished(context: &ScanContext, phase: u8, action_id: &str, status: RunStepStatus) {
540    let event_type = if status == RunStepStatus::Succeeded {
541        EventType::PhaseCompleted
542    } else {
543        EventType::PhaseFailed
544    };
545    context.event_bus.publish(
546        Event::builder(event_type, action_id)
547            .data("phase_number", phase.to_string())
548            .data("action_id", action_id)
549            .data("status", step_status_name(status))
550            .build(),
551    );
552}
553
554fn step_status_name(status: RunStepStatus) -> &'static str {
555    match status {
556        RunStepStatus::Succeeded => "succeeded",
557        RunStepStatus::Failed => "failed",
558        RunStepStatus::TimedOut => "timed_out",
559        RunStepStatus::Cancelled => "cancelled",
560        RunStepStatus::Skipped => "skipped",
561        RunStepStatus::BudgetExhausted => "budget_exhausted",
562        _ => "unknown",
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use std::{
569        future::pending,
570        sync::{
571            atomic::{AtomicBool, Ordering},
572            Arc,
573        },
574    };
575
576    use termivar_core::{
577        ConfidenceScore, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
578        Hypothesis, KnowledgePredicate, OutcomeStatus, Probability, ResourceAccountingMode,
579        RunStopCode, SecuritySeverity, VerificationStage, MAX_RUN_REPORT_TEXT_BYTES,
580    };
581    use tokio::sync::Notify;
582    use tokio_util::sync::CancellationToken;
583
584    use super::*;
585    use crate::{
586        rules::{Expression, KnowledgeLayer},
587        verification::{ActiveVerifier, VerificationCase, VerificationReport, VerificationRule},
588        Result, ScannerError,
589    };
590
591    const TYPED_TEST_ACTION_ID: &str = "legacy.observer.template_arithmetic";
592    const TYPED_TEST_CASE_ID: &str = "case:legacy.runner-test.review";
593    const TYPED_TEST_HYPOTHESIS_ID: &str = "hypothesis:legacy.runner-test.review";
594
595    fn invalid_bridge_error<T>(_error: T) -> ScannerError {
596        ScannerError::InvalidLegacyVerificationReport
597    }
598
599    fn typed_review_report(context: &ScanContext) -> Result<VerificationReport> {
600        let subject = context.legacy_verification_subject()?;
601        let predicate = KnowledgePredicate::new("legacy.runner-test", "review-signal")
602            .map_err(invalid_bridge_error)?;
603        let hypothesis = Hypothesis::with_id(
604            TYPED_TEST_HYPOTHESIS_ID,
605            subject.clone(),
606            KnowledgePredicate::new("legacy.runner-test", "audit-anchor")
607                .map_err(invalid_bridge_error)?,
608            EvidenceValue::Text("manual-review".to_owned()),
609            Probability::from_percent(50).map_err(invalid_bridge_error)?,
610        )
611        .map_err(invalid_bridge_error)?;
612        context
613            .knowledge()
614            .upsert_hypothesis(hypothesis)
615            .map_err(invalid_bridge_error)?;
616        let baseline = context.knowledge().snapshot_for_subject(&subject);
617
618        let source = EvidenceSource::new(TYPED_TEST_ACTION_ID, "bounded-review-observation")
619            .and_then(|source| source.with_correlation_id(TYPED_TEST_CASE_ID))
620            .map_err(invalid_bridge_error)?;
621        let evidence = Evidence::with_id_at(
622            EvidenceId::parse("evidence:legacy.runner-test.review")
623                .map_err(invalid_bridge_error)?,
624            subject.clone(),
625            EvidenceKind::Content,
626            predicate.clone(),
627            EvidenceValue::Boolean(true),
628            source,
629            ConfidenceScore::from_percent(70).map_err(invalid_bridge_error)?,
630            0,
631        );
632        context
633            .knowledge()
634            .insert_evidence(evidence)
635            .map_err(invalid_bridge_error)?;
636        let after_probe = context.knowledge().snapshot_for_subject(&subject);
637
638        let case = VerificationCase::new(
639            TYPED_TEST_CASE_ID,
640            subject,
641            TYPED_TEST_ACTION_ID,
642            TYPED_TEST_HYPOTHESIS_ID,
643        )
644        .map_err(invalid_bridge_error)?
645        .without_hypothesis_transition();
646        let rule = VerificationRule::new(
647            "verify:legacy.runner-test.needs-review",
648            VerificationStage::Active,
649            100,
650            Expression::equals(
651                KnowledgeLayer::Evidence,
652                predicate,
653                EvidenceValue::Boolean(true),
654            ),
655            OutcomeStatus::NeedsReview,
656            Probability::from_percent(70).map_err(invalid_bridge_error)?,
657            "Bounded observation requires review",
658        )
659        .map_err(invalid_bridge_error)?
660        .scoped_to_action(TYPED_TEST_ACTION_ID)
661        .map_err(invalid_bridge_error)?
662        .with_case_correlated_evidence()
663        .map_err(invalid_bridge_error)?;
664        let mut verifier = ActiveVerifier::new();
665        verifier.register(rule).map_err(invalid_bridge_error)?;
666        verifier
667            .verify_snapshots(&case, &baseline, &after_probe)
668            .map_err(invalid_bridge_error)
669    }
670
671    fn context(timeout_secs: u64, cancellation: CancellationToken) -> ScanContext {
672        context_for_target(
673            url::Url::parse(
674                "https://user:secret@example.test/path-secret-3d83a1?q=secret#fragment",
675            )
676            .unwrap(),
677            timeout_secs,
678            cancellation,
679        )
680    }
681
682    fn context_for_target(
683        target: url::Url,
684        timeout_secs: u64,
685        cancellation: CancellationToken,
686    ) -> ScanContext {
687        let (telemetry, _receiver) = tokio::sync::mpsc::unbounded_channel();
688        ScanContext::with_cancellation(
689            target,
690            reqwest::Client::new(),
691            telemetry,
692            timeout_secs,
693            cancellation,
694        )
695    }
696
697    fn raw_finding(
698        phase: u8,
699        module_name: &str,
700        severity: &str,
701        description: &str,
702        evidence: &str,
703    ) -> ScanFinding {
704        ScanFinding {
705            phase,
706            module_name: module_name.to_string(),
707            severity: severity.to_string(),
708            description: description.to_string(),
709            evidence: evidence.to_string(),
710        }
711    }
712
713    async fn report_for_findings(findings: Vec<ScanFinding>) -> RunReport {
714        let mut runner = ScanRunner::new();
715        runner.register_phase(phase(1, TestBehavior::Findings(findings)));
716        runner
717            .run_pipeline(context(1, CancellationToken::new()))
718            .await
719            .unwrap()
720    }
721
722    #[tokio::test]
723    async fn public_target_replacement_is_rejected_before_report_or_phase_effects() {
724        let authorized = url::Url::parse("https://authorized.example.test/root").unwrap();
725        let mut scan_context = context_for_target(authorized, 1, CancellationToken::new());
726        scan_context.target = url::Url::parse("https://replacement.example.test/secret").unwrap();
727        let executed = Arc::new(AtomicBool::new(false));
728        let mut runner = ScanRunner::new();
729        runner.register_phase(Box::new(CountingPhase {
730            executed: Arc::clone(&executed),
731        }));
732
733        let error = runner.run_pipeline(scan_context).await.unwrap_err();
734
735        assert_eq!(error, RunReportError::RunAuthorityMismatch);
736        assert!(!executed.load(Ordering::SeqCst));
737    }
738
739    fn outcome_identities(report: &RunReport) -> Vec<(String, String)> {
740        report
741            .outcomes()
742            .iter()
743            .map(|record| {
744                (
745                    record.action_id().to_string(),
746                    record.fingerprint().to_string(),
747                )
748            })
749            .collect()
750    }
751
752    struct TestPhase {
753        number: u8,
754        behavior: TestBehavior,
755    }
756
757    enum TestBehavior {
758        Success,
759        Error,
760        Budget,
761        Panic,
762        ChildJoinFailure {
763            sibling_entered: Arc<Notify>,
764            sibling_dropped: Arc<AtomicBool>,
765        },
766        Pending {
767            entered: Arc<Notify>,
768            dropped: Arc<AtomicBool>,
769        },
770        Findings(Vec<ScanFinding>),
771    }
772
773    struct DropSignal(Arc<AtomicBool>);
774
775    impl Drop for DropSignal {
776        fn drop(&mut self) {
777            self.0.store(true, Ordering::SeqCst);
778        }
779    }
780
781    #[async_trait::async_trait]
782    impl ScanPhase for TestPhase {
783        fn phase_number(&self) -> u8 {
784            self.number
785        }
786
787        fn name(&self) -> &'static str {
788            "test-phase"
789        }
790
791        async fn execute(&self, _context: &ScanContext) -> Result<Vec<ScanFinding>> {
792            match &self.behavior {
793                TestBehavior::Success => Ok(vec![ScanFinding {
794                    phase: self.number,
795                    module_name: "custom module".to_string(),
796                    severity: "HIGH".to_string(),
797                    description: "unverified target-controlled claim".to_string(),
798                    evidence: "Authorization: Bearer secret-value".to_string(),
799                }]),
800                TestBehavior::Error => Err(ScannerError::InvalidTarget),
801                TestBehavior::Budget => Err(ScannerError::BudgetExceeded(
802                    crate::RuntimeLimitExceeded::new(
803                        crate::RuntimeBudgetDimension::TotalRequests,
804                        1,
805                        2,
806                        Some("legacy.discovery.test".to_owned()),
807                    ),
808                )),
809                TestBehavior::Panic => panic!("target-controlled panic detail"),
810                TestBehavior::ChildJoinFailure {
811                    sibling_entered,
812                    sibling_dropped,
813                } => {
814                    let mut tasks = tokio::task::JoinSet::new();
815                    let entered = Arc::clone(sibling_entered);
816                    let dropped = Arc::clone(sibling_dropped);
817                    tasks.spawn(async move {
818                        let _drop_signal = DropSignal(dropped);
819                        entered.notify_one();
820                        pending::<()>().await;
821                    });
822                    sibling_entered.notified().await;
823
824                    let aborted = tasks.spawn(pending::<()>());
825                    aborted.abort();
826                    let _completed = collect_join_set(&mut tasks).await?;
827                    Ok(Vec::new())
828                },
829                TestBehavior::Pending { entered, dropped } => {
830                    let _drop_signal = DropSignal(Arc::clone(dropped));
831                    entered.notify_one();
832                    pending().await
833                },
834                TestBehavior::Findings(findings) => Ok(findings.clone()),
835            }
836        }
837    }
838
839    fn phase(number: u8, behavior: TestBehavior) -> Box<dyn ScanPhase> {
840        Box::new(TestPhase { number, behavior })
841    }
842
843    enum TypedBridgeBehavior {
844        SuccessWithRawFinding,
845        ErrorAfterRecord,
846        PanicAfterRecord,
847        JoinFailureAfterRecord,
848        BudgetAfterRecord,
849        DuplicateThenReturnRaw,
850        PendingAfterRecord {
851            entered: Arc<Notify>,
852            dropped: Arc<AtomicBool>,
853        },
854    }
855
856    struct TypedBridgePhase {
857        behavior: TypedBridgeBehavior,
858    }
859
860    #[async_trait::async_trait]
861    impl ScanPhase for TypedBridgePhase {
862        fn phase_number(&self) -> u8 {
863            7
864        }
865
866        fn name(&self) -> &'static str {
867            "typed-bridge-test"
868        }
869
870        async fn execute(&self, context: &ScanContext) -> Result<Vec<ScanFinding>> {
871            let report = typed_review_report(context)?;
872            match &self.behavior {
873                TypedBridgeBehavior::SuccessWithRawFinding => {
874                    context.record_legacy_verification_reports(vec![report])?;
875                    Ok(vec![raw_finding(
876                        7,
877                        "typed bridge test",
878                        "HIGH",
879                        "raw claim must not leak beside verifier output",
880                        "Authorization: Bearer raw-secret",
881                    )])
882                },
883                TypedBridgeBehavior::ErrorAfterRecord => {
884                    context.record_legacy_verification_reports(vec![report])?;
885                    Err(ScannerError::InvalidTarget)
886                },
887                TypedBridgeBehavior::PanicAfterRecord => {
888                    context.record_legacy_verification_reports(vec![report])?;
889                    panic!("typed bridge panic detail")
890                },
891                TypedBridgeBehavior::JoinFailureAfterRecord => {
892                    context.record_legacy_verification_reports(vec![report])?;
893                    Err(ScannerError::TaskJoinFailed)
894                },
895                TypedBridgeBehavior::BudgetAfterRecord => {
896                    context.record_legacy_verification_reports(vec![report])?;
897                    Err(ScannerError::BudgetExceeded(
898                        crate::RuntimeLimitExceeded::new(
899                            crate::RuntimeBudgetDimension::TotalRequests,
900                            1,
901                            2,
902                            Some(TYPED_TEST_ACTION_ID.to_owned()),
903                        ),
904                    ))
905                },
906                TypedBridgeBehavior::DuplicateThenReturnRaw => {
907                    context.record_legacy_verification_reports(vec![report.clone()])?;
908                    context.record_legacy_verification_reports(vec![report])?;
909                    Ok(vec![raw_finding(
910                        7,
911                        "typed bridge duplicate",
912                        "HIGH",
913                        "duplicate acceptance must not become output",
914                        "duplicate raw secret",
915                    )])
916                },
917                TypedBridgeBehavior::PendingAfterRecord { entered, dropped } => {
918                    context.record_legacy_verification_reports(vec![report])?;
919                    let _drop_signal = DropSignal(Arc::clone(dropped));
920                    entered.notify_one();
921                    pending().await
922                },
923            }
924        }
925    }
926
927    fn typed_phase(behavior: TypedBridgeBehavior) -> Box<dyn ScanPhase> {
928        Box::new(TypedBridgePhase { behavior })
929    }
930
931    #[tokio::test]
932    async fn typed_and_unresolved_outcomes_share_one_origin_subject_identity() {
933        let mut runner = ScanRunner::new();
934        runner.register_phase(typed_phase(TypedBridgeBehavior::SuccessWithRawFinding));
935        runner.register_phase(phase(
936            8,
937            TestBehavior::Findings(vec![raw_finding(
938                8,
939                "unresolved observation",
940                "INFO",
941                "bounded raw observation",
942                "withheld",
943            )]),
944        ));
945
946        let report = runner
947            .run_pipeline(context(1, CancellationToken::new()))
948            .await
949            .unwrap();
950
951        assert_eq!(report.outcomes().len(), 2);
952        assert!(report
953            .outcomes()
954            .iter()
955            .all(|outcome| outcome.subject() == report.outcomes()[0].subject()));
956    }
957
958    struct NamedPhase {
959        name: &'static str,
960    }
961
962    #[async_trait::async_trait]
963    impl ScanPhase for NamedPhase {
964        fn phase_number(&self) -> u8 {
965            7
966        }
967
968        fn name(&self) -> &'static str {
969            self.name
970        }
971
972        async fn execute(&self, _context: &ScanContext) -> Result<Vec<ScanFinding>> {
973            Ok(Vec::new())
974        }
975    }
976
977    struct CountingPhase {
978        executed: Arc<AtomicBool>,
979    }
980
981    #[async_trait::async_trait]
982    impl ScanPhase for CountingPhase {
983        fn phase_number(&self) -> u8 {
984            1
985        }
986
987        fn name(&self) -> &'static str {
988            "counting"
989        }
990
991        async fn execute(&self, _context: &ScanContext) -> Result<Vec<ScanFinding>> {
992            self.executed.store(true, Ordering::SeqCst);
993            Ok(Vec::new())
994        }
995    }
996
997    #[tokio::test]
998    async fn excessive_phase_registration_fails_before_any_phase_executes() {
999        let executed = Arc::new(AtomicBool::new(false));
1000        let phase: Arc<dyn ScanPhase> = Arc::new(CountingPhase {
1001            executed: Arc::clone(&executed),
1002        });
1003        let runner = ScanRunner {
1004            phases: vec![phase; MAX_RUN_REPORT_STEPS + 1],
1005        };
1006
1007        let error = runner
1008            .run_pipeline(context(1, CancellationToken::new()))
1009            .await
1010            .unwrap_err();
1011
1012        assert_eq!(
1013            error,
1014            RunReportError::TooMany {
1015                field: "registered phases",
1016                actual: MAX_RUN_REPORT_STEPS + 1,
1017                limit: MAX_RUN_REPORT_STEPS,
1018            }
1019        );
1020        assert!(!executed.load(Ordering::SeqCst));
1021    }
1022
1023    #[tokio::test]
1024    async fn equal_phase_numbers_use_stable_name_order() {
1025        let mut runner = ScanRunner::new();
1026        runner.register_phase(Box::new(NamedPhase { name: "zeta" }));
1027        runner.register_phase(Box::new(NamedPhase { name: "alpha" }));
1028
1029        let report = runner
1030            .run_pipeline(context(1, CancellationToken::new()))
1031            .await
1032            .unwrap();
1033
1034        assert!(report.steps()[0].action_id().contains("alpha"));
1035        assert!(report.steps()[1].action_id().contains("zeta"));
1036    }
1037
1038    #[tokio::test]
1039    async fn registration_order_does_not_change_equal_number_steps() {
1040        async fn action_ids(names: [&'static str; 2]) -> Vec<String> {
1041            let mut runner = ScanRunner::new();
1042            for name in names {
1043                runner.register_phase(Box::new(NamedPhase { name }));
1044            }
1045            runner
1046                .run_pipeline(context(1, CancellationToken::new()))
1047                .await
1048                .unwrap()
1049                .steps()
1050                .iter()
1051                .map(|step| step.action_id().to_string())
1052                .collect()
1053        }
1054
1055        assert_eq!(
1056            action_ids(["alpha", "zeta"]).await,
1057            action_ids(["zeta", "alpha"]).await
1058        );
1059    }
1060
1061    #[tokio::test]
1062    async fn duplicate_phase_identity_fails_before_any_phase_executes() {
1063        let first_executed = Arc::new(AtomicBool::new(false));
1064        let second_executed = Arc::new(AtomicBool::new(false));
1065        let mut runner = ScanRunner::new();
1066        runner.register_phase(Box::new(CountingPhase {
1067            executed: Arc::clone(&first_executed),
1068        }));
1069        runner.register_phase(Box::new(CountingPhase {
1070            executed: Arc::clone(&second_executed),
1071        }));
1072
1073        let error = runner
1074            .run_pipeline(context(1, CancellationToken::new()))
1075            .await
1076            .unwrap_err();
1077
1078        assert_eq!(error, RunReportError::DuplicateStepIdentity);
1079        assert!(!first_executed.load(Ordering::SeqCst));
1080        assert!(!second_executed.load(Ordering::SeqCst));
1081    }
1082
1083    #[tokio::test]
1084    async fn success_becomes_unknown_without_fabricated_evidence() {
1085        let mut runner = ScanRunner::new();
1086        runner.register_phase(phase(1, TestBehavior::Success));
1087
1088        let report = runner
1089            .run_pipeline(context(1, CancellationToken::new()))
1090            .await
1091            .unwrap();
1092
1093        assert_eq!(report.status(), RunStatus::Complete);
1094        assert_eq!(report.steps()[0].status(), RunStepStatus::Succeeded);
1095        assert_eq!(
1096            report.outcomes()[0].disposition(),
1097            termivar_core::OutcomeStatus::Unknown
1098        );
1099        assert_eq!(report.outcomes()[0].severity(), SecuritySeverity::Info);
1100        assert_eq!(report.outcomes()[0].confidence(), Probability::ZERO);
1101        assert!(report.outcomes()[0].evidence_ids().is_empty());
1102        let json = serde_json::to_string(&report).unwrap();
1103        assert!(!json.contains("secret-value"));
1104        assert!(!json.contains("unverified target-controlled claim"));
1105        assert!(!json.contains("user:secret"));
1106        assert!(!json.contains("path-secret-3d83a1"));
1107        assert!(!json.contains("q=secret"));
1108        assert!(!json.contains("fragment"));
1109        assert!(json.contains("unmetered"));
1110        assert_eq!(
1111            report.accounting().requests().mode(),
1112            ResourceAccountingMode::Unmetered
1113        );
1114        assert_eq!(
1115            report.accounting().wall_time_ms().mode(),
1116            ResourceAccountingMode::Observed
1117        );
1118    }
1119
1120    #[tokio::test]
1121    async fn verifier_owned_needs_review_suppresses_raw_unknown_projection() {
1122        let mut runner = ScanRunner::new();
1123        runner.register_phase(typed_phase(TypedBridgeBehavior::SuccessWithRawFinding));
1124
1125        let report = runner
1126            .run_pipeline(context(1, CancellationToken::new()))
1127            .await
1128            .unwrap();
1129
1130        assert_eq!(report.status(), RunStatus::Complete);
1131        assert_eq!(report.steps()[0].status(), RunStepStatus::Succeeded);
1132        assert_eq!(report.outcomes().len(), 1);
1133        let outcome = &report.outcomes()[0];
1134        assert_eq!(outcome.action_id(), TYPED_TEST_ACTION_ID);
1135        assert_eq!(outcome.disposition(), OutcomeStatus::NeedsReview);
1136        assert_eq!(outcome.severity(), SecuritySeverity::Info);
1137        assert_eq!(outcome.evidence_ids().len(), 1);
1138        assert_eq!(
1139            outcome.verification_outcome().unwrap().stage(),
1140            VerificationStage::Active
1141        );
1142        let wire = serde_json::to_string(&report).unwrap();
1143        assert!(!wire.contains("raw claim must not leak"));
1144        assert!(!wire.contains("raw-secret"));
1145        assert!(!report
1146            .outcomes()
1147            .iter()
1148            .any(|outcome| outcome.disposition() == OutcomeStatus::Unknown));
1149    }
1150
1151    #[tokio::test]
1152    async fn failure_after_verifier_record_rolls_back_the_phase_ledger() {
1153        let scan_context = context(1, CancellationToken::new());
1154        let ledger_view = scan_context.clone();
1155        let mut runner = ScanRunner::new();
1156        runner.register_phase(typed_phase(TypedBridgeBehavior::ErrorAfterRecord));
1157
1158        let report = runner.run_pipeline(scan_context).await.unwrap();
1159
1160        assert_eq!(report.status(), RunStatus::Failed);
1161        assert_eq!(report.steps()[0].status(), RunStepStatus::Failed);
1162        assert!(report.outcomes().is_empty());
1163        assert_eq!(ledger_view.legacy_verification_checkpoint(), 0);
1164    }
1165
1166    #[tokio::test]
1167    async fn every_terminal_failure_after_verifier_record_rolls_back_the_phase_ledger() {
1168        for (behavior, expected_step, expected_stop) in [
1169            (
1170                TypedBridgeBehavior::PanicAfterRecord,
1171                RunStepStatus::Failed,
1172                RunStopCode::TaskJoinFailed,
1173            ),
1174            (
1175                TypedBridgeBehavior::JoinFailureAfterRecord,
1176                RunStepStatus::Failed,
1177                RunStopCode::TaskJoinFailed,
1178            ),
1179            (
1180                TypedBridgeBehavior::BudgetAfterRecord,
1181                RunStepStatus::BudgetExhausted,
1182                RunStopCode::BudgetExhausted,
1183            ),
1184        ] {
1185            let scan_context = context(1, CancellationToken::new());
1186            let ledger_view = scan_context.clone();
1187            let mut runner = ScanRunner::new();
1188            runner.register_phase(typed_phase(behavior));
1189
1190            let report = runner.run_pipeline(scan_context).await.unwrap();
1191
1192            assert_eq!(report.steps()[0].status(), expected_step);
1193            assert_eq!(report.stop_reason().code(), expected_stop);
1194            assert!(report.outcomes().is_empty());
1195            assert_eq!(ledger_view.legacy_verification_checkpoint(), 0);
1196        }
1197    }
1198
1199    #[tokio::test]
1200    async fn timeout_after_verifier_record_rolls_back_the_phase_ledger() {
1201        let entered = Arc::new(Notify::new());
1202        let dropped = Arc::new(AtomicBool::new(false));
1203        let scan_context = context(1, CancellationToken::new());
1204        let ledger_view = scan_context.clone();
1205        let mut runner = ScanRunner::new();
1206        runner.register_phase(typed_phase(TypedBridgeBehavior::PendingAfterRecord {
1207            entered: Arc::clone(&entered),
1208            dropped: Arc::clone(&dropped),
1209        }));
1210
1211        let report = runner.run_pipeline(scan_context).await.unwrap();
1212
1213        assert_eq!(report.steps()[0].status(), RunStepStatus::TimedOut);
1214        assert_eq!(report.stop_reason().code(), RunStopCode::StepTimedOut);
1215        assert!(report.outcomes().is_empty());
1216        assert_eq!(ledger_view.legacy_verification_checkpoint(), 0);
1217        assert!(dropped.load(Ordering::SeqCst));
1218    }
1219
1220    #[tokio::test]
1221    async fn duplicate_verifier_fingerprint_fails_without_raw_unknown_leakage() {
1222        let scan_context = context(1, CancellationToken::new());
1223        let ledger_view = scan_context.clone();
1224        let mut runner = ScanRunner::new();
1225        runner.register_phase(typed_phase(TypedBridgeBehavior::DuplicateThenReturnRaw));
1226
1227        let report = runner.run_pipeline(scan_context).await.unwrap();
1228
1229        assert_eq!(report.status(), RunStatus::Failed);
1230        assert_eq!(report.steps()[0].status(), RunStepStatus::Failed);
1231        assert!(report.outcomes().is_empty());
1232        assert_eq!(ledger_view.legacy_verification_checkpoint(), 0);
1233        let wire = serde_json::to_string(&report).unwrap();
1234        assert!(!wire.contains("duplicate acceptance"));
1235        assert!(!wire.contains("duplicate raw secret"));
1236    }
1237
1238    #[tokio::test]
1239    async fn cancellation_after_verifier_record_rolls_back_the_phase_ledger() {
1240        let entered = Arc::new(Notify::new());
1241        let dropped = Arc::new(AtomicBool::new(false));
1242        let cancellation = CancellationToken::new();
1243        let scan_context = context(60, cancellation.clone());
1244        let ledger_view = scan_context.clone();
1245        let mut runner = ScanRunner::new();
1246        runner.register_phase(typed_phase(TypedBridgeBehavior::PendingAfterRecord {
1247            entered: Arc::clone(&entered),
1248            dropped: Arc::clone(&dropped),
1249        }));
1250        let task = tokio::spawn(async move { runner.run_pipeline(scan_context).await });
1251
1252        entered.notified().await;
1253        assert_eq!(ledger_view.legacy_verification_checkpoint(), 1);
1254        cancellation.cancel();
1255        let report = task.await.unwrap().unwrap();
1256
1257        assert_eq!(report.status(), RunStatus::Cancelled);
1258        assert_eq!(report.steps()[0].status(), RunStepStatus::Cancelled);
1259        assert!(report.outcomes().is_empty());
1260        assert_eq!(ledger_view.legacy_verification_checkpoint(), 0);
1261        assert!(dropped.load(Ordering::SeqCst));
1262    }
1263
1264    #[tokio::test]
1265    async fn phase_error_is_failed_and_does_not_hide_later_success() {
1266        let mut runner = ScanRunner::new();
1267        runner.register_phase(phase(1, TestBehavior::Error));
1268        runner.register_phase(phase(2, TestBehavior::Success));
1269
1270        let report = runner
1271            .run_pipeline(context(1, CancellationToken::new()))
1272            .await
1273            .unwrap();
1274
1275        assert_eq!(report.status(), RunStatus::Partial);
1276        assert_eq!(report.steps()[0].status(), RunStepStatus::Failed);
1277        assert_eq!(report.steps()[1].status(), RunStepStatus::Succeeded);
1278    }
1279
1280    #[tokio::test]
1281    async fn discovery_budget_exhaustion_is_typed_and_stops_dependent_phases() {
1282        let mut runner = ScanRunner::new();
1283        runner.register_phase(phase(1, TestBehavior::Success));
1284        runner.register_phase(phase(2, TestBehavior::Budget));
1285        runner.register_phase(phase(3, TestBehavior::Success));
1286
1287        let report = runner
1288            .run_pipeline(context(1, CancellationToken::new()))
1289            .await
1290            .unwrap();
1291
1292        assert_eq!(report.status(), RunStatus::Partial);
1293        assert_eq!(report.stop_reason().code(), RunStopCode::BudgetExhausted);
1294        assert_eq!(report.steps()[0].status(), RunStepStatus::Succeeded);
1295        assert_eq!(report.steps()[1].status(), RunStepStatus::BudgetExhausted);
1296        assert_eq!(report.steps()[2].status(), RunStepStatus::Skipped);
1297        assert_eq!(
1298            report.accounting().requests().mode(),
1299            ResourceAccountingMode::Unmetered,
1300            "remaining raw legacy phases keep the whole-run accounting contract unmetered"
1301        );
1302    }
1303
1304    #[tokio::test]
1305    async fn phase_panic_is_a_failed_step_not_empty_success() {
1306        let mut runner = ScanRunner::new();
1307        runner.register_phase(phase(1, TestBehavior::Panic));
1308
1309        let report = runner
1310            .run_pipeline(context(1, CancellationToken::new()))
1311            .await
1312            .unwrap();
1313
1314        assert_eq!(report.status(), RunStatus::Failed);
1315        assert_eq!(report.steps()[0].status(), RunStepStatus::Failed);
1316        assert_eq!(report.stop_reason().code(), RunStopCode::TaskJoinFailed);
1317        assert!(report.outcomes().is_empty());
1318    }
1319
1320    #[tokio::test]
1321    async fn child_join_failure_aborts_siblings_and_is_not_empty_success() {
1322        let sibling_entered = Arc::new(Notify::new());
1323        let sibling_dropped = Arc::new(AtomicBool::new(false));
1324        let mut runner = ScanRunner::new();
1325        runner.register_phase(phase(
1326            1,
1327            TestBehavior::ChildJoinFailure {
1328                sibling_entered,
1329                sibling_dropped: Arc::clone(&sibling_dropped),
1330            },
1331        ));
1332
1333        let report = runner
1334            .run_pipeline(context(1, CancellationToken::new()))
1335            .await
1336            .unwrap();
1337
1338        assert_eq!(report.status(), RunStatus::Failed);
1339        assert_eq!(report.steps()[0].status(), RunStepStatus::Failed);
1340        assert_eq!(report.stop_reason().code(), RunStopCode::TaskJoinFailed);
1341        assert!(report.outcomes().is_empty());
1342        assert!(sibling_dropped.load(Ordering::SeqCst));
1343        assert_eq!(
1344            report.steps()[0].detail(),
1345            Some("Legacy phase execution did not complete normally; no result was accepted.")
1346        );
1347    }
1348
1349    #[tokio::test]
1350    async fn timeout_drops_the_owned_phase_future() {
1351        let entered = Arc::new(Notify::new());
1352        let dropped = Arc::new(AtomicBool::new(false));
1353        let mut runner = ScanRunner::new();
1354        runner.register_phase(phase(
1355            1,
1356            TestBehavior::Pending {
1357                entered,
1358                dropped: Arc::clone(&dropped),
1359            },
1360        ));
1361
1362        let report = runner
1363            .run_pipeline(context(1, CancellationToken::new()))
1364            .await
1365            .unwrap();
1366
1367        assert_eq!(report.status(), RunStatus::Failed);
1368        assert_eq!(report.steps()[0].status(), RunStepStatus::TimedOut);
1369        assert!(dropped.load(Ordering::SeqCst));
1370    }
1371
1372    #[tokio::test]
1373    async fn zero_phase_timeout_denies_execution_before_polling() {
1374        let executed = Arc::new(AtomicBool::new(false));
1375        let mut runner = ScanRunner::new();
1376        runner.register_phase(Box::new(CountingPhase {
1377            executed: Arc::clone(&executed),
1378        }));
1379
1380        let report = runner
1381            .run_pipeline(context(0, CancellationToken::new()))
1382            .await
1383            .unwrap();
1384
1385        assert_eq!(report.status(), RunStatus::Failed);
1386        assert_eq!(report.stop_reason().code(), RunStopCode::StepTimedOut);
1387        assert_eq!(report.steps()[0].status(), RunStepStatus::TimedOut);
1388        assert!(!executed.load(Ordering::SeqCst));
1389    }
1390
1391    #[tokio::test]
1392    async fn caller_timeout_drops_run_future_and_aborts_the_phase_task() {
1393        let entered = Arc::new(Notify::new());
1394        let dropped = Arc::new(AtomicBool::new(false));
1395        let mut runner = ScanRunner::new();
1396        runner.register_phase(phase(
1397            1,
1398            TestBehavior::Pending {
1399                entered: Arc::clone(&entered),
1400                dropped: Arc::clone(&dropped),
1401            },
1402        ));
1403
1404        let result = tokio::time::timeout(
1405            Duration::from_millis(25),
1406            runner.run_pipeline(context(60, CancellationToken::new())),
1407        )
1408        .await;
1409
1410        assert!(result.is_err());
1411        tokio::task::yield_now().await;
1412        assert!(dropped.load(Ordering::SeqCst));
1413    }
1414
1415    #[tokio::test]
1416    async fn cancellation_aborts_active_task_and_skips_remaining_phases() {
1417        let entered = Arc::new(Notify::new());
1418        let dropped = Arc::new(AtomicBool::new(false));
1419        let cancellation = CancellationToken::new();
1420        let mut runner = ScanRunner::new();
1421        runner.register_phase(phase(
1422            1,
1423            TestBehavior::Pending {
1424                entered: Arc::clone(&entered),
1425                dropped: Arc::clone(&dropped),
1426            },
1427        ));
1428        runner.register_phase(phase(2, TestBehavior::Success));
1429        let run_cancellation = cancellation.clone();
1430        let task =
1431            tokio::spawn(async move { runner.run_pipeline(context(60, run_cancellation)).await });
1432
1433        entered.notified().await;
1434        cancellation.cancel();
1435        let report = task.await.unwrap().unwrap();
1436
1437        assert_eq!(report.status(), RunStatus::Cancelled);
1438        assert_eq!(report.steps()[0].status(), RunStepStatus::Cancelled);
1439        assert_eq!(report.steps()[1].status(), RunStepStatus::Skipped);
1440        assert!(dropped.load(Ordering::SeqCst));
1441    }
1442
1443    #[tokio::test]
1444    async fn pre_cancelled_run_marks_every_phase_skipped() {
1445        let cancellation = CancellationToken::new();
1446        cancellation.cancel();
1447        let mut runner = ScanRunner::new();
1448        runner.register_phase(phase(1, TestBehavior::Success));
1449        runner.register_phase(phase(2, TestBehavior::Success));
1450
1451        let report = runner.run_pipeline(context(1, cancellation)).await.unwrap();
1452
1453        assert_eq!(report.status(), RunStatus::Cancelled);
1454        assert_eq!(report.stop_reason().code(), RunStopCode::Cancelled);
1455        assert!(report
1456            .steps()
1457            .iter()
1458            .all(|step| step.status() == RunStepStatus::Skipped));
1459        assert!(report.outcomes().is_empty());
1460    }
1461
1462    #[tokio::test]
1463    async fn pre_cancelled_empty_run_is_cancelled() {
1464        let cancellation = CancellationToken::new();
1465        cancellation.cancel();
1466
1467        let report = ScanRunner::new()
1468            .run_pipeline(context(1, cancellation))
1469            .await
1470            .unwrap();
1471
1472        assert_eq!(report.status(), RunStatus::Cancelled);
1473        assert_eq!(report.stop_reason().code(), RunStopCode::Cancelled);
1474        assert!(report.steps().is_empty());
1475        assert!(report.outcomes().is_empty());
1476    }
1477
1478    #[tokio::test]
1479    async fn invalid_report_envelope_fails_before_phase_execution() {
1480        let executed = Arc::new(AtomicBool::new(false));
1481        let mut runner = ScanRunner::new();
1482        runner.register_phase(Box::new(CountingPhase {
1483            executed: Arc::clone(&executed),
1484        }));
1485        let scheme = "a".repeat(MAX_RUN_REPORT_TEXT_BYTES + 1);
1486        let target = url::Url::parse(&format!("{scheme}:opaque-target")).unwrap();
1487
1488        let error = runner
1489            .run_pipeline(context_for_target(target, 1, CancellationToken::new()))
1490            .await
1491            .unwrap_err();
1492
1493        assert_eq!(
1494            error,
1495            RunReportError::TextTooLong {
1496                field: "run target",
1497                limit: MAX_RUN_REPORT_TEXT_BYTES,
1498            }
1499        );
1500        assert!(!executed.load(Ordering::SeqCst));
1501    }
1502
1503    #[test]
1504    fn target_rendering_redacts_path_and_strips_credentials_query_and_fragment() {
1505        let raw_path = "/path-secret-b33791";
1506        let url = url::Url::parse(&format!(
1507            "https://user:secret@[2001:db8::1]:8443{raw_path}?q=token#fragment"
1508        ))
1509        .unwrap();
1510        let rendered = redacted_target(&url);
1511
1512        assert_eq!(rendered, "https://[2001:db8::1]:8443/[path-redacted]");
1513        assert!(!rendered.contains(raw_path));
1514        assert_eq!(authorized_origin(&url), "https://[2001:db8::1]:8443");
1515    }
1516
1517    #[test]
1518    fn different_non_root_paths_have_the_same_redacted_target() {
1519        let first = url::Url::parse("https://example.test/first-secret").unwrap();
1520        let second = url::Url::parse("https://example.test/second-secret/nested").unwrap();
1521
1522        assert_eq!(redacted_target(&first), redacted_target(&second));
1523        assert_eq!(
1524            redacted_target(&first),
1525            "https://example.test/[path-redacted]"
1526        );
1527    }
1528
1529    #[test]
1530    fn authority_less_target_contains_no_raw_value_or_digest() {
1531        let url = url::Url::parse("mailto:raw-recipient-secret@example.test").unwrap();
1532
1533        assert_eq!(redacted_target(&url), "mailto:[target-redacted]");
1534    }
1535
1536    #[test]
1537    fn authority_less_origins_use_a_constant_without_raw_values() {
1538        let first = url::Url::parse("mailto:first-origin-secret@example.test").unwrap();
1539        let second = url::Url::parse("mailto:second-origin-secret@example.test").unwrap();
1540        let first_origin = authorized_origin(&first);
1541        let second_origin = authorized_origin(&second);
1542
1543        assert_eq!(first_origin, "mailto:[origin-unavailable]");
1544        assert_eq!(first_origin, second_origin);
1545        assert!(!first_origin.contains("first-origin-secret"));
1546        assert!(!second_origin.contains("second-origin-secret"));
1547    }
1548
1549    #[test]
1550    fn root_target_path_is_preserved_exactly() {
1551        let url = url::Url::parse("https://example.test/?q=secret#fragment").unwrap();
1552
1553        assert_eq!(redacted_target(&url), "https://example.test/");
1554    }
1555
1556    #[test]
1557    fn fingerprints_are_stable_and_domain_framed() {
1558        assert_eq!(sha256_hex(&[b"ab", b"c"]), sha256_hex(&[b"ab", b"c"]));
1559        assert_ne!(sha256_hex(&[b"ab", b"c"]), sha256_hex(&[b"a", b"bc"]));
1560    }
1561
1562    #[tokio::test]
1563    async fn equivalent_raw_sets_have_stable_projected_fingerprints() {
1564        let first = vec![
1565            raw_finding(1, "untrusted-module", "CRITICAL", "z", "secret-z"),
1566            raw_finding(1, "untrusted-module", "CRITICAL", "a", "secret-a"),
1567        ];
1568        let second = first.iter().rev().cloned().collect();
1569        let first_report = report_for_findings(first).await;
1570        let second_report = report_for_findings(second).await;
1571
1572        assert_eq!(
1573            outcome_identities(&first_report),
1574            outcome_identities(&second_report)
1575        );
1576        assert_eq!(first_report.outcomes().len(), 1);
1577        assert!(first_report.outcomes()[0]
1578            .action_id()
1579            .ends_with(".observations"));
1580    }
1581
1582    #[tokio::test]
1583    async fn adding_unrelated_raw_finding_does_not_change_phase_aggregate_identity() {
1584        let first = raw_finding(1, "module", "LOW", "first", "evidence-first");
1585        let second = raw_finding(1, "module", "LOW", "second", "evidence-second");
1586        let unrelated = raw_finding(1, "aaa", "INFO", "sorts first", "unrelated");
1587        let baseline = report_for_findings(vec![first.clone(), second.clone()]).await;
1588        let expanded = report_for_findings(vec![first, unrelated, second]).await;
1589        let baseline_identities = outcome_identities(&baseline);
1590        let expanded_identities = outcome_identities(&expanded);
1591
1592        assert_eq!(baseline_identities.len(), 1);
1593        assert_eq!(expanded_identities.len(), 1);
1594        assert_eq!(baseline_identities, expanded_identities);
1595    }
1596
1597    #[tokio::test]
1598    async fn same_count_different_raw_content_has_the_same_aggregate_identity() {
1599        let first = report_for_findings(vec![raw_finding(
1600            1,
1601            "module",
1602            "LOW",
1603            "description",
1604            "evidence-a",
1605        )])
1606        .await;
1607        let second = report_for_findings(vec![raw_finding(
1608            1,
1609            "module",
1610            "LOW",
1611            "description",
1612            "evidence-b",
1613        )])
1614        .await;
1615
1616        assert_eq!(first.outcomes().len(), 1);
1617        assert_eq!(second.outcomes().len(), 1);
1618        assert_eq!(outcome_identities(&first), outcome_identities(&second));
1619    }
1620
1621    #[tokio::test]
1622    async fn no_raw_field_contributes_to_public_aggregate_identity() {
1623        let baseline = raw_finding(1, "module", "LOW", "description", "evidence");
1624        let variants = [
1625            raw_finding(2, "module", "LOW", "description", "evidence"),
1626            raw_finding(1, "other", "LOW", "description", "evidence"),
1627            raw_finding(1, "module", "HIGH", "description", "evidence"),
1628            raw_finding(1, "module", "LOW", "other", "evidence"),
1629            raw_finding(1, "module", "LOW", "description", "other"),
1630        ];
1631        let baseline_report = report_for_findings(vec![baseline]).await;
1632        let baseline_identity = outcome_identities(&baseline_report);
1633
1634        for variant in variants {
1635            let variant_report = report_for_findings(vec![variant]).await;
1636            assert_eq!(variant_report.outcomes().len(), 1);
1637            assert_eq!(outcome_identities(&variant_report), baseline_identity);
1638        }
1639    }
1640
1641    #[tokio::test]
1642    async fn identical_raw_findings_collapse_to_one_phase_aggregate() {
1643        let duplicate = raw_finding(1, "module", "LOW", "duplicate", "same evidence");
1644        let report = report_for_findings(vec![duplicate.clone(), duplicate]).await;
1645
1646        assert_eq!(report.outcomes().len(), 1);
1647    }
1648
1649    #[tokio::test]
1650    async fn serialized_projection_does_not_expose_raw_finding_fields() {
1651        let raw_values = [
1652            "raw-module-secret-71c65e",
1653            "raw-severity-secret-6bbd49",
1654            "raw-description-secret-02a8a1",
1655            "raw-evidence-secret-30d38d",
1656        ];
1657        let report = report_for_findings(vec![raw_finding(
1658            1,
1659            raw_values[0],
1660            raw_values[1],
1661            raw_values[2],
1662            raw_values[3],
1663        )])
1664        .await;
1665        let serialized = serde_json::to_string(&report).unwrap();
1666
1667        assert!(raw_values.iter().all(|value| !serialized.contains(value)));
1668    }
1669
1670    #[tokio::test]
1671    async fn many_raw_findings_still_produce_one_complete_phase_aggregate() {
1672        let findings = (0..128)
1673            .map(|index| ScanFinding {
1674                phase: 1,
1675                module_name: "untrusted-module".to_string(),
1676                severity: "HIGH".to_string(),
1677                description: format!("unresolved-{index}"),
1678                evidence: "withheld".to_string(),
1679            })
1680            .collect();
1681        let mut runner = ScanRunner::new();
1682        runner.register_phase(phase(1, TestBehavior::Findings(findings)));
1683
1684        let report = runner
1685            .run_pipeline(context(1, CancellationToken::new()))
1686            .await
1687            .unwrap();
1688
1689        assert_eq!(report.status(), RunStatus::Complete);
1690        assert_eq!(report.stop_reason().code(), RunStopCode::Completed);
1691        assert_eq!(report.outcomes().len(), 1);
1692        assert!(report.steps()[0].status() == RunStepStatus::Succeeded);
1693    }
1694}