1use std::{sync::Arc, time::Duration};
11
12use reqwest::Client;
13use termivar_core::RunReport;
14use tokio_util::sync::CancellationToken;
15use url::Url;
16
17use crate::{
18 DiscoveryLimits, EventBus, Result, ScanContext, ScanPhase, ScanRunner, VerificationLimits,
19};
20
21pub struct ScannerSdk {
27 runner: ScanRunner,
28 client: Client,
29 discovery_limits: DiscoveryLimits,
30 verification_limits: VerificationLimits,
31 phase_timeout_secs: u64,
32 event_bus: Arc<EventBus>,
33}
34
35impl ScannerSdk {
36 pub fn builder() -> ScannerBuilder {
38 ScannerBuilder::new()
39 }
40
41 pub async fn scan(&self, target: &str) -> Result<RunReport> {
43 self.scan_with_cancellation(target, CancellationToken::new())
44 .await
45 }
46
47 pub async fn scan_with_cancellation(
49 &self,
50 target: &str,
51 cancellation: CancellationToken,
52 ) -> Result<RunReport> {
53 let target_url = Url::parse(target)?;
54 let (telemetry, receiver) = tokio::sync::mpsc::unbounded_channel();
55 drop(receiver);
56 let context = ScanContext::with_event_bus(
57 target_url,
58 self.client.clone(),
59 telemetry,
60 self.phase_timeout_secs,
61 cancellation,
62 Arc::clone(&self.event_bus),
63 )
64 .with_pre_execution_discovery_limits(self.discovery_limits)
65 .with_pre_execution_verification_limits(self.verification_limits);
66 Ok(self.runner.run_pipeline(context).await?)
67 }
68
69 pub fn event_bus(&self) -> Arc<EventBus> {
71 Arc::clone(&self.event_bus)
72 }
73}
74
75pub struct ScannerBuilder {
77 phases: Vec<Box<dyn ScanPhase>>,
78 client: Client,
79 discovery_limits: DiscoveryLimits,
80 verification_limits: VerificationLimits,
81 phase_timeout_secs: u64,
82 event_bus: Arc<EventBus>,
83}
84
85impl ScannerBuilder {
86 pub fn new() -> Self {
88 Self {
89 phases: Vec::new(),
90 client: Client::new(),
91 discovery_limits: DiscoveryLimits::default(),
92 verification_limits: VerificationLimits::default(),
93 phase_timeout_secs: 300,
94 event_bus: Arc::new(EventBus::new()),
95 }
96 }
97
98 pub fn phase<P>(mut self, phase: P) -> Self
100 where
101 P: ScanPhase + 'static,
102 {
103 self.phases.push(Box::new(phase));
104 self
105 }
106
107 pub fn boxed_phase(mut self, phase: Box<dyn ScanPhase>) -> Self {
109 self.phases.push(phase);
110 self
111 }
112
113 pub fn client(mut self, client: Client) -> Self {
117 self.client = client;
118 self
119 }
120
121 pub fn discovery_limits(mut self, limits: DiscoveryLimits) -> Self {
124 self.discovery_limits = limits;
125 self
126 }
127
128 pub fn verification_limits(mut self, limits: VerificationLimits) -> Self {
131 self.verification_limits = limits;
132 self
133 }
134
135 pub fn phase_timeout(mut self, timeout: Duration) -> Self {
137 self.phase_timeout_secs = timeout.as_secs().max(1);
138 self
139 }
140
141 pub fn event_bus(mut self, event_bus: Arc<EventBus>) -> Self {
143 self.event_bus = event_bus;
144 self
145 }
146
147 pub fn build(self) -> ScannerSdk {
149 let mut runner = ScanRunner::new();
150 for phase in self.phases {
151 runner.register_phase(phase);
152 }
153 ScannerSdk {
154 runner,
155 client: self.client,
156 discovery_limits: self.discovery_limits,
157 verification_limits: self.verification_limits,
158 phase_timeout_secs: self.phase_timeout_secs,
159 event_bus: self.event_bus,
160 }
161 }
162}
163
164impl Default for ScannerBuilder {
165 fn default() -> Self {
166 Self::new()
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use termivar_core::{OutcomeStatus, Probability, RunStatus, RunStepStatus, SecuritySeverity};
173
174 use super::*;
175 use crate::ScanFinding;
176
177 struct ExamplePhase;
178
179 #[async_trait::async_trait]
180 impl ScanPhase for ExamplePhase {
181 fn phase_number(&self) -> u8 {
182 42
183 }
184
185 fn name(&self) -> &'static str {
186 "example"
187 }
188
189 async fn execute(&self, context: &ScanContext) -> Result<Vec<ScanFinding>> {
190 Ok(vec![ScanFinding {
191 phase: self.phase_number(),
192 module_name: self.name().to_string(),
193 severity: "HIGH".to_string(),
194 description: "SDK phase executed".to_string(),
195 evidence: context.target.to_string(),
196 }])
197 }
198 }
199
200 #[tokio::test]
201 async fn custom_scanner_returns_only_the_typed_report_boundary() {
202 let scanner = ScannerSdk::builder().phase(ExamplePhase).build();
203 let report = scanner.scan("https://example.test").await.unwrap();
204
205 assert_eq!(report.status(), RunStatus::Complete);
206 assert_eq!(report.steps()[0].status(), RunStepStatus::Succeeded);
207 assert_eq!(report.outcomes()[0].disposition(), OutcomeStatus::Unknown);
208 assert_eq!(report.outcomes()[0].severity(), SecuritySeverity::Info);
209 assert_eq!(report.outcomes()[0].confidence(), Probability::ZERO);
210 let json = serde_json::to_string(&report).unwrap();
211 assert!(!json.contains("SDK phase executed"));
212 assert!(!json.contains("HIGH"));
213 }
214
215 #[test]
216 fn builder_retains_explicit_discovery_limits() {
217 let limits = DiscoveryLimits::new().with_max_requests(7);
218 let scanner = ScannerSdk::builder().discovery_limits(limits).build();
219
220 assert_eq!(scanner.discovery_limits.max_requests(), 7);
221 }
222
223 #[test]
224 fn builder_retains_explicit_verification_limits() {
225 let limits = VerificationLimits::new().with_max_requests(11).unwrap();
226 let scanner = ScannerSdk::builder().verification_limits(limits).build();
227
228 assert_eq!(scanner.verification_limits.max_requests(), 11);
229 }
230}