1use serde::{Deserialize, Serialize};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum CheckId {
44 WebDriverFlag,
46 ChromeObject,
48 PluginCount,
50 LanguagesPresent,
52 CanvasConsistency,
54 WebGlVendor,
56 AutomationGlobals,
58 OuterWindowSize,
60 HeadlessUserAgent,
62 NotificationPermission,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct CheckResult {
71 pub id: CheckId,
73 pub description: String,
75 pub passed: bool,
77 pub details: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct DiagnosticReport {
100 pub checks: Vec<CheckResult>,
102 pub passed_count: usize,
104 pub failed_count: usize,
106}
107
108impl DiagnosticReport {
109 pub fn new(checks: Vec<CheckResult>) -> Self {
111 let passed_count = checks.iter().filter(|r| r.passed).count();
112 let failed_count = checks.len() - passed_count;
113 Self {
114 checks,
115 passed_count,
116 failed_count,
117 }
118 }
119
120 #[must_use]
122 pub const fn is_clean(&self) -> bool {
123 self.failed_count == 0
124 }
125
126 #[allow(clippy::cast_precision_loss)]
128 pub fn coverage_pct(&self) -> f64 {
129 if self.checks.is_empty() {
130 return 0.0;
131 }
132 self.passed_count as f64 / self.checks.len() as f64 * 100.0
133 }
134
135 pub fn failures(&self) -> impl Iterator<Item = &CheckResult> {
137 self.checks.iter().filter(|r| !r.passed)
138 }
139}
140
141pub struct DetectionCheck {
146 pub id: CheckId,
148 pub description: &'static str,
150 pub script: &'static str,
156}
157
158impl DetectionCheck {
159 pub fn parse_output(&self, json: &str) -> CheckResult {
165 #[derive(Deserialize)]
166 struct Output {
167 passed: bool,
168 #[serde(default)]
169 details: String,
170 }
171
172 match serde_json::from_str::<Output>(json) {
173 Ok(o) => CheckResult {
174 id: self.id,
175 description: self.description.to_string(),
176 passed: o.passed,
177 details: o.details,
178 },
179 Err(e) => CheckResult {
180 id: self.id,
181 description: self.description.to_string(),
182 passed: false,
183 details: format!("parse error: {e} | raw: {json}"),
184 },
185 }
186 }
187}
188
189const SCRIPT_WEBDRIVER: &str = concat!(
192 "JSON.stringify({",
193 "passed:navigator.webdriver===false||navigator.webdriver===undefined,",
194 "details:String(navigator.webdriver)",
195 "})"
196);
197
198const SCRIPT_CHROME_OBJECT: &str = concat!(
199 "JSON.stringify({",
200 "passed:typeof window.chrome!=='undefined'&&window.chrome!==null",
201 "&&typeof window.chrome.runtime!=='undefined',",
202 "details:typeof window.chrome",
203 "})"
204);
205
206const SCRIPT_PLUGIN_COUNT: &str = concat!(
207 "JSON.stringify({",
208 "passed:navigator.plugins.length>0,",
209 "details:navigator.plugins.length+' plugins'",
210 "})"
211);
212
213const SCRIPT_LANGUAGES: &str = concat!(
214 "JSON.stringify({",
215 "passed:Array.isArray(navigator.languages)&&navigator.languages.length>0,",
216 "details:JSON.stringify(navigator.languages)",
217 "})"
218);
219
220const SCRIPT_CANVAS: &str = concat!(
221 "(function(){",
222 "var c=document.createElement('canvas');",
223 "c.width=200;c.height=50;",
224 "var ctx=c.getContext('2d');",
225 "ctx.fillStyle='#1a2b3c';ctx.fillRect(0,0,200,50);",
226 "ctx.font='16px Arial';ctx.fillStyle='#fafafa';",
227 "ctx.fillText('stygian-diag',10,30);",
228 "var d=c.toDataURL();",
229 "return JSON.stringify({passed:d.length>200,details:'len='+d.length});",
230 "})()"
231);
232
233const SCRIPT_WEBGL_VENDOR: &str = concat!(
234 "(function(){",
235 "var gl=document.createElement('canvas').getContext('webgl');",
236 "if(!gl)return JSON.stringify({passed:false,details:'webgl unavailable'});",
237 "var ext=gl.getExtension('WEBGL_debug_renderer_info');",
238 "if(!ext)return JSON.stringify({passed:true,details:'debug ext absent (normal)'});",
239 "var v=gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)||'';",
240 "var r=gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)||'';",
241 "var sw=v.includes('SwiftShader')||r.includes('SwiftShader');",
242 "return JSON.stringify({passed:!sw,details:v+'/'+r});",
243 "})()"
244);
245
246const SCRIPT_AUTOMATION_GLOBALS: &str = concat!(
247 "JSON.stringify({",
248 "passed:typeof window.__puppeteer__==='undefined'",
249 "&&typeof window.__playwright==='undefined'",
250 "&&typeof window.__webdriverFunc==='undefined'",
251 "&&typeof window._phantom==='undefined',",
252 "details:'automation globals checked'",
253 "})"
254);
255
256const SCRIPT_OUTER_WINDOW: &str = concat!(
257 "JSON.stringify({",
258 "passed:window.outerWidth>0&&window.outerHeight>0,",
259 "details:window.outerWidth+'x'+window.outerHeight",
260 "})"
261);
262
263const SCRIPT_HEADLESS_UA: &str = concat!(
264 "JSON.stringify({",
265 "passed:!navigator.userAgent.includes('HeadlessChrome'),",
266 "details:navigator.userAgent.substring(0,100)",
267 "})"
268);
269
270const SCRIPT_NOTIFICATION: &str = concat!(
271 "JSON.stringify({",
272 "passed:typeof Notification==='undefined'||Notification.permission!=='granted',",
273 "details:typeof Notification!=='undefined'?Notification.permission:'unavailable'",
274 "})"
275);
276
277pub fn all_checks() -> &'static [DetectionCheck] {
284 CHECKS
285}
286
287static CHECKS: &[DetectionCheck] = &[
288 DetectionCheck {
289 id: CheckId::WebDriverFlag,
290 description: "navigator.webdriver must be false/undefined",
291 script: SCRIPT_WEBDRIVER,
292 },
293 DetectionCheck {
294 id: CheckId::ChromeObject,
295 description: "window.chrome.runtime must exist",
296 script: SCRIPT_CHROME_OBJECT,
297 },
298 DetectionCheck {
299 id: CheckId::PluginCount,
300 description: "navigator.plugins must be non-empty",
301 script: SCRIPT_PLUGIN_COUNT,
302 },
303 DetectionCheck {
304 id: CheckId::LanguagesPresent,
305 description: "navigator.languages must be non-empty",
306 script: SCRIPT_LANGUAGES,
307 },
308 DetectionCheck {
309 id: CheckId::CanvasConsistency,
310 description: "canvas toDataURL must return non-trivial image data",
311 script: SCRIPT_CANVAS,
312 },
313 DetectionCheck {
314 id: CheckId::WebGlVendor,
315 description: "WebGL vendor must not be SwiftShader (software renderer)",
316 script: SCRIPT_WEBGL_VENDOR,
317 },
318 DetectionCheck {
319 id: CheckId::AutomationGlobals,
320 description: "automation globals (Puppeteer/Playwright) must be absent",
321 script: SCRIPT_AUTOMATION_GLOBALS,
322 },
323 DetectionCheck {
324 id: CheckId::OuterWindowSize,
325 description: "window.outerWidth/outerHeight must be non-zero",
326 script: SCRIPT_OUTER_WINDOW,
327 },
328 DetectionCheck {
329 id: CheckId::HeadlessUserAgent,
330 description: "User-Agent must not contain 'HeadlessChrome'",
331 script: SCRIPT_HEADLESS_UA,
332 },
333 DetectionCheck {
334 id: CheckId::NotificationPermission,
335 description: "Notification.permission must not be pre-granted",
336 script: SCRIPT_NOTIFICATION,
337 },
338];
339
340#[cfg(test)]
343#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
344mod tests {
345 use super::*;
346 use std::collections::HashSet;
347
348 #[test]
349 fn all_checks_returns_ten_entries() {
350 assert_eq!(all_checks().len(), 10);
351 }
352
353 #[test]
354 fn all_checks_have_unique_ids() {
355 let ids: HashSet<_> = all_checks().iter().map(|c| c.id).collect();
356 assert_eq!(
357 ids.len(),
358 all_checks().len(),
359 "duplicate check ids detected"
360 );
361 }
362
363 #[test]
364 fn all_checks_have_non_empty_scripts_with_json_stringify() {
365 for check in all_checks() {
366 assert!(
367 !check.script.is_empty(),
368 "check {:?} has empty script",
369 check.id
370 );
371 assert!(
372 check.script.contains("JSON.stringify"),
373 "check {:?} script must produce a JSON string",
374 check.id
375 );
376 }
377 }
378
379 #[test]
380 fn parse_output_valid_passing_json() {
381 let check = &all_checks()[0]; let result = check.parse_output(r#"{"passed":true,"details":"undefined"}"#);
383 assert!(result.passed);
384 assert_eq!(result.id, CheckId::WebDriverFlag);
385 assert_eq!(result.details, "undefined");
386 }
387
388 #[test]
389 fn parse_output_valid_failing_json() {
390 let check = &all_checks()[0];
391 let result = check.parse_output(r#"{"passed":false,"details":"true"}"#);
392 assert!(!result.passed);
393 }
394
395 #[test]
396 fn parse_output_invalid_json_returns_fail_with_details() {
397 let check = &all_checks()[0];
398 let result = check.parse_output("not json at all");
399 assert!(!result.passed);
400 assert!(result.details.contains("parse error"));
401 }
402
403 #[test]
404 fn parse_output_preserves_check_id() {
405 let check = all_checks()
406 .iter()
407 .find(|c| c.id == CheckId::ChromeObject)
408 .unwrap();
409 let result = check.parse_output(r#"{"passed":true,"details":"object"}"#);
410 assert_eq!(result.id, CheckId::ChromeObject);
411 assert_eq!(result.description, check.description);
412 }
413
414 #[test]
415 fn parse_output_missing_details_defaults_to_empty() {
416 let check = &all_checks()[0];
417 let result = check.parse_output(r#"{"passed":true}"#);
418 assert!(result.passed);
419 assert!(result.details.is_empty());
420 }
421
422 #[test]
423 fn diagnostic_report_all_passing() {
424 let results: Vec<CheckResult> = all_checks()
425 .iter()
426 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
427 .collect();
428 let report = DiagnosticReport::new(results);
429 assert!(report.is_clean());
430 assert_eq!(report.passed_count, 10);
431 assert_eq!(report.failed_count, 0);
432 assert!((report.coverage_pct() - 100.0).abs() < 0.001);
433 assert_eq!(report.failures().count(), 0);
434 }
435
436 #[test]
437 fn diagnostic_report_some_failing() {
438 let mut results: Vec<CheckResult> = all_checks()
439 .iter()
440 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
441 .collect();
442 results[0].passed = false;
443 results[2].passed = false;
444 let report = DiagnosticReport::new(results);
445 assert!(!report.is_clean());
446 assert_eq!(report.failed_count, 2);
447 assert_eq!(report.passed_count, 8);
448 assert_eq!(report.failures().count(), 2);
449 }
450
451 #[test]
452 fn diagnostic_report_empty_checks() {
453 let report = DiagnosticReport::new(Vec::new());
454 assert!(report.is_clean()); assert!((report.coverage_pct()).abs() < 0.001);
456 }
457
458 #[test]
459 fn check_result_serializes_with_snake_case_id() {
460 let result = CheckResult {
461 id: CheckId::WebDriverFlag,
462 description: "test".to_string(),
463 passed: true,
464 details: "ok".to_string(),
465 };
466 let json = serde_json::to_string(&result).unwrap();
467 assert!(json.contains("\"web_driver_flag\""), "got: {json}");
468 assert!(json.contains("\"passed\":true"));
469 }
470
471 #[test]
472 fn diagnostic_report_serializes_and_deserializes() {
473 let results: Vec<CheckResult> = all_checks()
474 .iter()
475 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
476 .collect();
477 let report = DiagnosticReport::new(results);
478 let json = serde_json::to_string(&report).unwrap();
479 let restored: DiagnosticReport = serde_json::from_str(&json).unwrap();
480 assert_eq!(restored.passed_count, report.passed_count);
481 assert!(restored.is_clean());
482 }
483}