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    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
208        let mut root = Map::new();
209        root.insert(
210            "started_at_epoch_secs".to_string(),
211            Value::from(self.started_at_epoch_secs),
212        );
213        root.insert("passed".to_string(), Value::from(self.passed));
214        root.insert("failed".to_string(), Value::from(self.failed));
215
216        let mut results = Vec::with_capacity(self.results.len());
217        for item in &self.results {
218            let mut item_obj = Map::new();
219
220            let mut target_obj = Map::new();
221            target_obj.insert(
222                "target".to_string(),
223                serde_json::to_value(item.target.target)?,
224            );
225            target_obj.insert("name".to_string(), Value::from(item.target.name));
226            target_obj.insert("url".to_string(), Value::from(item.target.url));
227            target_obj.insert(
228                "category".to_string(),
229                Value::from(item.target.category.to_string()),
230            );
231            target_obj.insert(
232                "timeout_secs".to_string(),
233                Value::from(item.target.timeout.as_secs_f64()),
234            );
235
236            let mut result_obj = Map::new();
237            result_obj.insert(
238                "target".to_string(),
239                serde_json::to_value(item.result.target)?,
240            );
241            result_obj.insert("passed".to_string(), Value::from(item.result.passed));
242            if let Some(score) = item.result.score {
243                result_obj.insert("score".to_string(), Value::from(score));
244            } else {
245                result_obj.insert("score".to_string(), Value::Null);
246            }
247            result_obj.insert(
248                "elapsed_secs".to_string(),
249                Value::from(item.result.elapsed.as_secs_f64()),
250            );
251            result_obj.insert(
252                "screenshot".to_string(),
253                if item.result.screenshot.is_some() {
254                    Value::from("present")
255                } else {
256                    Value::Null
257                },
258            );
259
260            let detail_map: BTreeMap<String, String> = item
261                .result
262                .details
263                .iter()
264                .map(|(k, v)| (k.clone(), v.clone()))
265                .collect();
266            result_obj.insert("details".to_string(), serde_json::to_value(detail_map)?);
267
268            item_obj.insert("target".to_string(), Value::Object(target_obj));
269            item_obj.insert("result".to_string(), Value::Object(result_obj));
270            results.push(Value::Object(item_obj));
271        }
272
273        root.insert("results".to_string(), Value::Array(results));
274        serde_json::to_string_pretty(&Value::Object(root))
275    }
276
277    /// Generate a markdown summary table and per-target detail section.
278    #[must_use]
279    pub fn to_markdown(&self) -> String {
280        let mut out = String::new();
281        out.push_str("# Stealth Benchmark Report\n\n");
282        let _ = writeln!(
283            out,
284            "- started_at_epoch_secs: {}",
285            self.started_at_epoch_secs
286        );
287        let _ = writeln!(out, "- passed: {}", self.passed);
288        let _ = writeln!(out, "- failed: {}", self.failed);
289        out.push('\n');
290
291        out.push_str("| Target | Category | Passed | Score | Elapsed (s) |\n");
292        out.push_str("|---|---|---:|---:|---:|\n");
293        for item in &self.results {
294            let score = item
295                .result
296                .score
297                .map_or_else(|| "-".to_string(), |v| format!("{v:.3}"));
298            let _ = writeln!(
299                out,
300                "| {} | {} | {} | {} | {:.3} |",
301                item.target.name,
302                item.target.category,
303                if item.result.passed { "yes" } else { "no" },
304                score,
305                item.result.elapsed.as_secs_f64()
306            );
307        }
308
309        out.push_str("\n## Details\n\n");
310        for item in &self.results {
311            let _ = writeln!(out, "### {} ({})", item.target.name, item.target.url);
312            out.push('\n');
313            let _ = writeln!(out, "- passed: {}", item.result.passed);
314            let _ = writeln!(out, "- elapsed_s: {:.3}", item.result.elapsed.as_secs_f64());
315            if let Some(score) = item.result.score {
316                let _ = writeln!(out, "- score: {score:.3}");
317            }
318            if !item.result.details.is_empty() {
319                out.push_str("- details:\n");
320                for (k, v) in sorted_details(&item.result.details) {
321                    let _ = writeln!(out, "  - {k}: {v}");
322                }
323            }
324            out.push('\n');
325        }
326
327        out
328    }
329}
330
331/// Benchmark harness entrypoint.
332pub struct StealthBenchmark;
333
334impl StealthBenchmark {
335    /// Run benchmark according to config.
336    pub async fn run(pool: &Arc<BrowserPool>, config: &BenchmarkConfig) -> BenchmarkReport {
337        let started_at_epoch_secs = SystemTime::now()
338            .duration_since(UNIX_EPOCH)
339            .unwrap_or(Duration::ZERO)
340            .as_secs();
341
342        let targets = config.resolved_targets();
343        let mut results = Vec::with_capacity(targets.len());
344
345        for target in targets {
346            let benchmark_target = BenchmarkTarget::from_validation_target(target);
347            let timeout = config.timeout_override.unwrap_or(benchmark_target.timeout);
348
349            let run = tokio::time::timeout(timeout, ValidationSuite::run_one(pool, target)).await;
350            let result = run.unwrap_or_else(|_| {
351                ValidationResult::failed(
352                    target,
353                    &format!("benchmark timeout after {}s", timeout.as_secs()),
354                )
355            });
356
357            let passed = result.passed;
358            results.push(BenchmarkItem {
359                target: benchmark_target,
360                result,
361            });
362
363            if !config.continue_on_error && !passed {
364                break;
365            }
366        }
367
368        let passed = results.iter().filter(|r| r.result.passed).count();
369        let failed = results.len().saturating_sub(passed);
370
371        BenchmarkReport {
372            started_at_epoch_secs,
373            passed,
374            failed,
375            results,
376        }
377    }
378}
379
380fn sorted_details(details: &HashMap<String, String>) -> Vec<(String, String)> {
381    let mut ordered: Vec<(String, String)> = details
382        .iter()
383        .map(|(k, v)| (k.clone(), v.clone()))
384        .collect();
385    ordered.sort_by(|a, b| a.0.cmp(&b.0));
386    ordered
387}
388
389mod duration_secs {
390    use std::time::Duration;
391
392    use serde::Serializer;
393
394    pub(super) fn serialize<S>(d: &Duration, serializer: S) -> Result<S::Ok, S::Error>
395    where
396        S: Serializer,
397    {
398        serializer.serialize_f64(d.as_secs_f64())
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn config_parsing_and_filtering() {
408        let names = vec![
409            "creepjs".to_string(),
410            "cloudflare".to_string(),
411            "invalid".to_string(),
412        ];
413        let parsed = BenchmarkConfig::parse_target_names(&names);
414        assert_eq!(
415            parsed,
416            vec![ValidationTarget::CreepJs, ValidationTarget::Cloudflare]
417        );
418
419        let cfg = BenchmarkConfig {
420            targets: parsed,
421            tier1_only: true,
422            continue_on_error: true,
423            timeout_override: None,
424        };
425        assert_eq!(cfg.resolved_targets(), ValidationTarget::tier1().to_vec());
426    }
427
428    #[test]
429    fn report_json_is_deterministic_for_same_input() {
430        let report = BenchmarkReport {
431            started_at_epoch_secs: 123,
432            passed: 1,
433            failed: 1,
434            results: vec![
435                BenchmarkItem {
436                    target: BenchmarkTarget::from_validation_target(ValidationTarget::CreepJs),
437                    result: ValidationResult {
438                        target: ValidationTarget::CreepJs,
439                        passed: true,
440                        score: Some(0.95),
441                        details: HashMap::from([
442                            ("b".to_string(), "2".to_string()),
443                            ("a".to_string(), "1".to_string()),
444                        ]),
445                        screenshot: None,
446                        elapsed: Duration::from_secs(1),
447                    },
448                },
449                BenchmarkItem {
450                    target: BenchmarkTarget::from_validation_target(ValidationTarget::BrowserScan),
451                    result: ValidationResult::failed(ValidationTarget::BrowserScan, "blocked"),
452                },
453            ],
454        };
455
456        let first = report.to_json_pretty();
457        let second = report.to_json_pretty();
458
459        assert!(first.is_ok());
460        assert!(second.is_ok());
461
462        let first_json = first.unwrap_or_default();
463        let second_json = second.unwrap_or_default();
464        assert_eq!(first_json, second_json);
465    }
466
467    #[cfg(feature = "stealth")]
468    #[tokio::test]
469    #[ignore = "requires live network and browser runtime"]
470    async fn live_target_schema_completeness() {
471        use crate::BrowserConfig;
472
473        let pool_result = crate::pool::BrowserPool::new(BrowserConfig::default()).await;
474        let Ok(pool) = pool_result else {
475            // Environment-dependent ignored test: no panic path for strict clippy.
476            return;
477        };
478
479        let config = BenchmarkConfig {
480            targets: vec![ValidationTarget::CreepJs],
481            tier1_only: false,
482            continue_on_error: true,
483            timeout_override: Some(Duration::from_secs(15)),
484        };
485
486        let report = StealthBenchmark::run(&pool, &config).await;
487        assert_eq!(report.results.len(), 1);
488        let item = report.results.first();
489        assert!(item.is_some());
490        let Some(item) = item else {
491            return;
492        };
493        assert_eq!(item.target.target, ValidationTarget::CreepJs);
494        assert!(!item.target.url.is_empty());
495        assert!(!item.target.name.is_empty());
496    }
497}