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> {
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 #[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
331pub struct StealthBenchmark;
333
334impl StealthBenchmark {
335 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 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}