Skip to main content

termivar_scanner/
sdk.rs

1//! Public composition API for the opt-in historical scanner SDK.
2//!
3//! The SDK returns the shared typed [`RunReport`]
4//! contract. Raw phase telemetry and `ScanFinding` strings do not cross this
5//! host boundary. Built-in phases 2–4 and 5–9 receive separate finite
6//! discovery and active-verification envelopes. Phase 1 or a custom phase may
7//! still use the compatibility client, so whole-run request/body accounting is
8//! `Unmetered`.
9
10use 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
21/// Reusable legacy scanner assembled from application-defined [`ScanPhase`]
22/// values.
23///
24/// Custom phases can use the raw compatibility client and therefore cannot
25/// inherit the bounded-accounting claim of either built-in transport slice.
26pub 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    /// Starts a custom scanner builder.
37    pub fn builder() -> ScannerBuilder {
38        ScannerBuilder::new()
39    }
40
41    /// Executes configured phases against an authorized target.
42    pub async fn scan(&self, target: &str) -> Result<RunReport> {
43        self.scan_with_cancellation(target, CancellationToken::new())
44            .await
45    }
46
47    /// Executes configured phases with a host-owned cancellation token.
48    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    /// Returns the event bus used by this scanner for host subscriptions.
70    pub fn event_bus(&self) -> Arc<EventBus> {
71        Arc::clone(&self.event_bus)
72    }
73}
74
75/// Builder for a custom [`ScannerSdk`].
76pub 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    /// Creates a builder with a five-minute phase timeout.
87    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    /// Adds a phase. Execution order is determined by `phase_number()`.
99    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    /// Adds a boxed phase selected dynamically by the host.
108    pub fn boxed_phase(mut self, phase: Box<dyn ScanPhase>) -> Self {
109        self.phases.push(phase);
110        self
111    }
112
113    /// Replaces the raw HTTP client used by phase one and custom legacy
114    /// phases. Built-in phases two through nine use isolated, bounded,
115    /// redirect-disabled authorities instead.
116    pub fn client(mut self, client: Client) -> Self {
117        self.client = client;
118        self
119    }
120
121    /// Replaces the finite exact-origin envelope shared by built-in discovery
122    /// phases 2–4.
123    pub fn discovery_limits(mut self, limits: DiscoveryLimits) -> Self {
124        self.discovery_limits = limits;
125        self
126    }
127
128    /// Replaces the finite exact-origin envelope shared by built-in legacy
129    /// verification phases five through nine.
130    pub fn verification_limits(mut self, limits: VerificationLimits) -> Self {
131        self.verification_limits = limits;
132        self
133    }
134
135    /// Sets a minimum one-second timeout for each phase.
136    pub fn phase_timeout(mut self, timeout: Duration) -> Self {
137        self.phase_timeout_secs = timeout.as_secs().max(1);
138        self
139    }
140
141    /// Replaces the event bus used for lifecycle publication.
142    pub fn event_bus(mut self, event_bus: Arc<EventBus>) -> Self {
143        self.event_bus = event_bus;
144        self
145    }
146
147    /// Builds a reusable scanner.
148    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}