1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum BenchmarkCategory {
23 Fingerprint,
25 Challenge,
27 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct BenchmarkTarget {
45 pub target: ValidationTarget,
47 pub name: &'static str,
49 pub url: &'static str,
51 pub category: BenchmarkCategory,
53 #[serde(with = "duration_secs")]
55 pub timeout: Duration,
56}
57
58impl BenchmarkTarget {
59 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
125pub struct BenchmarkConfig {
126 pub targets: Vec<ValidationTarget>,
128 pub tier1_only: bool,
130 pub continue_on_error: bool,
132 #[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 #[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 #[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#[derive(Debug, Clone, Serialize)]
185pub struct BenchmarkItem {
186 pub target: BenchmarkTarget,
188 pub result: ValidationResult,
190}
191
192#[derive(Debug, Clone, Serialize)]
194pub struct BenchmarkReport {
195 pub started_at_epoch_secs: u64,
197 pub passed: usize,
199 pub failed: usize,
201 pub results: Vec<BenchmarkItem>,
203}
204
205impl BenchmarkReport {
206 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 #[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
337pub struct StealthBenchmark;
339
340impl StealthBenchmark {
341 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 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}