Skip to main content

stygian_browser/validation/
benchmark.rs

1//! Stealth benchmark harness for anti-bot validation targets.
2//!
3//! The harness executes one or more [`ValidationTarget`] validators and emits
4//! deterministic JSON/Markdown reports for local runs and CI artifacts.
5
6use std::collections::{BTreeMap, HashMap};
7use std::fmt;
8use std::fmt::Write as _;
9use std::sync::Arc;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use serde::Serialize;
13use serde_json::{Map, Value};
14
15use crate::pool::BrowserPool;
16
17use super::{ValidationResult, ValidationSuite, ValidationTarget};
18
19/// Benchmark target category.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum BenchmarkCategory {
23    /// Browser fingerprint observatories/scanners.
24    Fingerprint,
25    /// Challenge-heavy bot-protection pages.
26    Challenge,
27    /// Network/IP/WebRTC leak checks.
28    NetworkLeak,
29}
30
31impl fmt::Display for BenchmarkCategory {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        let value = match self {
34            Self::Fingerprint => "fingerprint",
35            Self::Challenge => "challenge",
36            Self::NetworkLeak => "network_leak",
37        };
38        f.write_str(value)
39    }
40}
41
42/// Static benchmark metadata for a validation target.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct BenchmarkTarget {
45    /// Validation target enum key.
46    pub target: ValidationTarget,
47    /// Human readable target name.
48    pub name: &'static str,
49    /// Target URL.
50    pub url: &'static str,
51    /// Benchmark category.
52    pub category: BenchmarkCategory,
53    /// Per-target timeout for execution.
54    #[serde(with = "duration_secs")]
55    pub timeout: Duration,
56}
57
58impl BenchmarkTarget {
59    /// Build benchmark metadata from a [`ValidationTarget`].
60    #[must_use]
61    pub const fn from_validation_target(target: ValidationTarget) -> Self {
62        match target {
63            ValidationTarget::CreepJs => Self {
64                target,
65                name: "CreepJS",
66                url: target.url(),
67                category: BenchmarkCategory::Fingerprint,
68                timeout: Duration::from_secs(45),
69            },
70            ValidationTarget::BrowserScan => Self {
71                target,
72                name: "BrowserScan",
73                url: target.url(),
74                category: BenchmarkCategory::NetworkLeak,
75                timeout: Duration::from_secs(45),
76            },
77            ValidationTarget::FingerprintJs => Self {
78                target,
79                name: "FingerprintJS",
80                url: target.url(),
81                category: BenchmarkCategory::Fingerprint,
82                timeout: Duration::from_secs(45),
83            },
84            ValidationTarget::Kasada => Self {
85                target,
86                name: "Kasada",
87                url: target.url(),
88                category: BenchmarkCategory::Challenge,
89                timeout: Duration::from_secs(45),
90            },
91            ValidationTarget::Cloudflare => Self {
92                target,
93                name: "Cloudflare",
94                url: target.url(),
95                category: BenchmarkCategory::Challenge,
96                timeout: Duration::from_secs(45),
97            },
98            ValidationTarget::Akamai => Self {
99                target,
100                name: "Akamai",
101                url: target.url(),
102                category: BenchmarkCategory::Challenge,
103                timeout: Duration::from_secs(45),
104            },
105            ValidationTarget::DataDome => Self {
106                target,
107                name: "DataDome",
108                url: target.url(),
109                category: BenchmarkCategory::Challenge,
110                timeout: Duration::from_secs(45),
111            },
112            ValidationTarget::PerimeterX => Self {
113                target,
114                name: "PerimeterX",
115                url: target.url(),
116                category: BenchmarkCategory::Challenge,
117                timeout: Duration::from_secs(45),
118            },
119        }
120    }
121}
122
123/// Benchmark runtime configuration.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
125pub struct BenchmarkConfig {
126    /// Targets to execute in order.
127    pub targets: Vec<ValidationTarget>,
128    /// Restrict execution to Tier-1 CI-safe targets.
129    pub tier1_only: bool,
130    /// Continue running remaining targets after one fails.
131    pub continue_on_error: bool,
132    /// Optional override for every target timeout.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub timeout_override: Option<Duration>,
135}
136
137impl Default for BenchmarkConfig {
138    fn default() -> Self {
139        Self {
140            targets: ValidationTarget::tier1().to_vec(),
141            tier1_only: true,
142            continue_on_error: true,
143            timeout_override: None,
144        }
145    }
146}
147
148impl BenchmarkConfig {
149    /// Resolve the effective target list from config flags.
150    #[must_use]
151    pub fn resolved_targets(&self) -> Vec<ValidationTarget> {
152        if self.tier1_only {
153            return ValidationTarget::tier1().to_vec();
154        }
155
156        if self.targets.is_empty() {
157            return ValidationTarget::tier1().to_vec();
158        }
159
160        self.targets.clone()
161    }
162
163    /// Parse user-facing target names into enum values.
164    #[must_use]
165    pub fn parse_target_names(names: &[String]) -> Vec<ValidationTarget> {
166        names
167            .iter()
168            .filter_map(|name| match name.trim().to_ascii_lowercase().as_str() {
169                "creepjs" => Some(ValidationTarget::CreepJs),
170                "browserscan" => Some(ValidationTarget::BrowserScan),
171                "fingerprintjs" | "fingerprint_js" => Some(ValidationTarget::FingerprintJs),
172                "kasada" => Some(ValidationTarget::Kasada),
173                "cloudflare" => Some(ValidationTarget::Cloudflare),
174                "akamai" => Some(ValidationTarget::Akamai),
175                "datadome" | "data_dome" => Some(ValidationTarget::DataDome),
176                "perimeterx" | "perimeter_x" => Some(ValidationTarget::PerimeterX),
177                _ => None,
178            })
179            .collect()
180    }
181}
182
183/// One benchmark execution item.
184#[derive(Debug, Clone, Serialize)]
185pub struct BenchmarkItem {
186    /// Target metadata.
187    pub target: BenchmarkTarget,
188    /// Validator output.
189    pub result: ValidationResult,
190}
191
192/// Full benchmark report.
193#[derive(Debug, Clone, Serialize)]
194pub struct BenchmarkReport {
195    /// Unix timestamp (seconds) for run start.
196    pub started_at_epoch_secs: u64,
197    /// Number of passed validations.
198    pub passed: usize,
199    /// Number of failed validations.
200    pub failed: usize,
201    /// Result entries in execution order.
202    pub results: Vec<BenchmarkItem>,
203}
204
205impl BenchmarkReport {
206    /// Serialize the report as deterministic pretty JSON.
207    ///
208    /// # Errors
209    ///
210    /// Returns [`serde_json::Error`] if any of the report fields cannot
211    /// be serialised (this should not occur for the supported numeric,
212    /// string, and `Vec<BenchmarkItem>` fields).
213    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
214        let mut root = Map::new();
215        root.insert(
216            "started_at_epoch_secs".to_string(),
217            Value::from(self.started_at_epoch_secs),
218        );
219        root.insert("passed".to_string(), Value::from(self.passed));
220        root.insert("failed".to_string(), Value::from(self.failed));
221
222        let mut results = Vec::with_capacity(self.results.len());
223        for item in &self.results {
224            let mut item_obj = Map::new();
225
226            let mut target_obj = Map::new();
227            target_obj.insert(
228                "target".to_string(),
229                serde_json::to_value(item.target.target)?,
230            );
231            target_obj.insert("name".to_string(), Value::from(item.target.name));
232            target_obj.insert("url".to_string(), Value::from(item.target.url));
233            target_obj.insert(
234                "category".to_string(),
235                Value::from(item.target.category.to_string()),
236            );
237            target_obj.insert(
238                "timeout_secs".to_string(),
239                Value::from(item.target.timeout.as_secs_f64()),
240            );
241
242            let mut result_obj = Map::new();
243            result_obj.insert(
244                "target".to_string(),
245                serde_json::to_value(item.result.target)?,
246            );
247            result_obj.insert("passed".to_string(), Value::from(item.result.passed));
248            if let Some(score) = item.result.score {
249                result_obj.insert("score".to_string(), Value::from(score));
250            } else {
251                result_obj.insert("score".to_string(), Value::Null);
252            }
253            result_obj.insert(
254                "elapsed_secs".to_string(),
255                Value::from(item.result.elapsed.as_secs_f64()),
256            );
257            result_obj.insert(
258                "screenshot".to_string(),
259                if item.result.screenshot.is_some() {
260                    Value::from("present")
261                } else {
262                    Value::Null
263                },
264            );
265
266            let detail_map: BTreeMap<String, String> = item
267                .result
268                .details
269                .iter()
270                .map(|(k, v)| (k.clone(), v.clone()))
271                .collect();
272            result_obj.insert("details".to_string(), serde_json::to_value(detail_map)?);
273
274            item_obj.insert("target".to_string(), Value::Object(target_obj));
275            item_obj.insert("result".to_string(), Value::Object(result_obj));
276            results.push(Value::Object(item_obj));
277        }
278
279        root.insert("results".to_string(), Value::Array(results));
280        serde_json::to_string_pretty(&Value::Object(root))
281    }
282
283    /// Generate a markdown summary table and per-target detail section.
284    #[must_use]
285    pub fn to_markdown(&self) -> String {
286        let mut out = String::new();
287        out.push_str("# Stealth Benchmark Report\n\n");
288        let _ = writeln!(
289            out,
290            "- started_at_epoch_secs: {}",
291            self.started_at_epoch_secs
292        );
293        let _ = writeln!(out, "- passed: {}", self.passed);
294        let _ = writeln!(out, "- failed: {}", self.failed);
295        out.push('\n');
296
297        out.push_str("| Target | Category | Passed | Score | Elapsed (s) |\n");
298        out.push_str("|---|---|---:|---:|---:|\n");
299        for item in &self.results {
300            let score = item
301                .result
302                .score
303                .map_or_else(|| "-".to_string(), |v| format!("{v:.3}"));
304            let _ = writeln!(
305                out,
306                "| {} | {} | {} | {} | {:.3} |",
307                item.target.name,
308                item.target.category,
309                if item.result.passed { "yes" } else { "no" },
310                score,
311                item.result.elapsed.as_secs_f64()
312            );
313        }
314
315        out.push_str("\n## Details\n\n");
316        for item in &self.results {
317            let _ = writeln!(out, "### {} ({})", item.target.name, item.target.url);
318            out.push('\n');
319            let _ = writeln!(out, "- passed: {}", item.result.passed);
320            let _ = writeln!(out, "- elapsed_s: {:.3}", item.result.elapsed.as_secs_f64());
321            if let Some(score) = item.result.score {
322                let _ = writeln!(out, "- score: {score:.3}");
323            }
324            if !item.result.details.is_empty() {
325                out.push_str("- details:\n");
326                for (k, v) in sorted_details(&item.result.details) {
327                    let _ = writeln!(out, "  - {k}: {v}");
328                }
329            }
330            out.push('\n');
331        }
332
333        out
334    }
335}
336
337/// Benchmark harness entrypoint.
338pub struct StealthBenchmark;
339
340impl StealthBenchmark {
341    /// Run benchmark according to config.
342    pub async fn run(pool: &Arc<BrowserPool>, config: &BenchmarkConfig) -> BenchmarkReport {
343        let started_at_epoch_secs = SystemTime::now()
344            .duration_since(UNIX_EPOCH)
345            .unwrap_or(Duration::ZERO)
346            .as_secs();
347
348        let targets = config.resolved_targets();
349        let mut results = Vec::with_capacity(targets.len());
350
351        for target in targets {
352            let benchmark_target = BenchmarkTarget::from_validation_target(target);
353            let timeout = config.timeout_override.unwrap_or(benchmark_target.timeout);
354
355            let run = tokio::time::timeout(timeout, ValidationSuite::run_one(pool, target)).await;
356            let result = run.unwrap_or_else(|_| {
357                ValidationResult::failed(
358                    target,
359                    &format!("benchmark timeout after {}s", timeout.as_secs()),
360                )
361            });
362
363            let passed = result.passed;
364            results.push(BenchmarkItem {
365                target: benchmark_target,
366                result,
367            });
368
369            if !config.continue_on_error && !passed {
370                break;
371            }
372        }
373
374        let passed = results.iter().filter(|r| r.result.passed).count();
375        let failed = results.len().saturating_sub(passed);
376
377        BenchmarkReport {
378            started_at_epoch_secs,
379            passed,
380            failed,
381            results,
382        }
383    }
384}
385
386fn sorted_details(details: &HashMap<String, String>) -> Vec<(String, String)> {
387    let mut ordered: Vec<(String, String)> = details
388        .iter()
389        .map(|(k, v)| (k.clone(), v.clone()))
390        .collect();
391    ordered.sort_by(|a, b| a.0.cmp(&b.0));
392    ordered
393}
394
395mod duration_secs {
396    use std::time::Duration;
397
398    use serde::Serializer;
399
400    pub(super) fn serialize<S>(d: &Duration, serializer: S) -> Result<S::Ok, S::Error>
401    where
402        S: Serializer,
403    {
404        serializer.serialize_f64(d.as_secs_f64())
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn config_parsing_and_filtering() {
414        let names = vec![
415            "creepjs".to_string(),
416            "cloudflare".to_string(),
417            "invalid".to_string(),
418        ];
419        let parsed = BenchmarkConfig::parse_target_names(&names);
420        assert_eq!(
421            parsed,
422            vec![ValidationTarget::CreepJs, ValidationTarget::Cloudflare]
423        );
424
425        let cfg = BenchmarkConfig {
426            targets: parsed,
427            tier1_only: true,
428            continue_on_error: true,
429            timeout_override: None,
430        };
431        assert_eq!(cfg.resolved_targets(), ValidationTarget::tier1().to_vec());
432    }
433
434    #[test]
435    fn report_json_is_deterministic_for_same_input() {
436        let report = BenchmarkReport {
437            started_at_epoch_secs: 123,
438            passed: 1,
439            failed: 1,
440            results: vec![
441                BenchmarkItem {
442                    target: BenchmarkTarget::from_validation_target(ValidationTarget::CreepJs),
443                    result: ValidationResult {
444                        target: ValidationTarget::CreepJs,
445                        passed: true,
446                        score: Some(0.95),
447                        details: HashMap::from([
448                            ("b".to_string(), "2".to_string()),
449                            ("a".to_string(), "1".to_string()),
450                        ]),
451                        screenshot: None,
452                        elapsed: Duration::from_secs(1),
453                    },
454                },
455                BenchmarkItem {
456                    target: BenchmarkTarget::from_validation_target(ValidationTarget::BrowserScan),
457                    result: ValidationResult::failed(ValidationTarget::BrowserScan, "blocked"),
458                },
459            ],
460        };
461
462        let first = report.to_json_pretty();
463        let second = report.to_json_pretty();
464
465        assert!(first.is_ok());
466        assert!(second.is_ok());
467
468        let first_json = first.unwrap_or_default();
469        let second_json = second.unwrap_or_default();
470        assert_eq!(first_json, second_json);
471    }
472
473    #[cfg(feature = "stealth")]
474    #[tokio::test]
475    #[ignore = "requires live network and browser runtime"]
476    async fn live_target_schema_completeness() {
477        use crate::BrowserConfig;
478
479        let pool_result = crate::pool::BrowserPool::new(BrowserConfig::default()).await;
480        let Ok(pool) = pool_result else {
481            // Environment-dependent ignored test: no panic path for strict clippy.
482            return;
483        };
484
485        let config = BenchmarkConfig {
486            targets: vec![ValidationTarget::CreepJs],
487            tier1_only: false,
488            continue_on_error: true,
489            timeout_override: Some(Duration::from_secs(15)),
490        };
491
492        let report = StealthBenchmark::run(&pool, &config).await;
493        assert_eq!(report.results.len(), 1);
494        let item = report.results.first();
495        assert!(item.is_some());
496        let Some(item) = item else {
497            return;
498        };
499        assert_eq!(item.target.target, ValidationTarget::CreepJs);
500        assert!(!item.target.url.is_empty());
501        assert!(!item.target.name.is_empty());
502    }
503}