1use 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
38pub 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 pub fn new() -> Self {
57 Self { phases: Vec::new() }
58 }
59
60 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 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 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#[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}