mod common;
use common::{Detector, dump};
use std::time::Duration;
const URL: &str = "https://abrahamjuliot.github.io/creepjs/";
const READY: &str = r#"document.querySelectorAll('span.hash').length >= 20 ? 'yes' : 'no'"#;
const SCRAPE: &str = r#"(() => {
const panels = [...document.querySelectorAll('span.hash')].map((el) => {
let label = '';
for (let n = el.previousSibling; n; n = n.previousSibling) {
const t = (n.textContent || '').trim();
if (t) { label = t; break; }
}
const cls = [...el.classList].filter((c) => c !== 'hash');
return { panel: label, hash: (el.textContent || '').trim(), flags: cls };
}).filter((p) => p.panel);
const text = document.body.innerText || '';
const grab = (re) => { const m = text.match(re); return m ? m[1] : null; };
return JSON.stringify({
panels,
flagged: panels.filter((p) => p.flags.length),
trash: grab(/trash \((\d+)\)/),
lies: grab(/lies \((\d+)\)/),
likeHeadless: grab(/(\d+)% like headless/),
headless: grab(/(\d+)% headless/),
stealth: grab(/(\d+)% stealth/),
});
})()"#;
const PROGRESS: &str = r#"JSON.stringify({
readyState: document.readyState,
hashes: document.querySelectorAll('span.hash').length,
bodyChars: (document.body && document.body.innerText || '').length,
hasWorker: typeof Worker !== 'undefined',
swRegistrations: (navigator.serviceWorker && navigator.serviceWorker.controller) ? 1 : 0,
head: (document.body && document.body.innerText || '').slice(0, 200),
})"#;
#[tokio::test]
#[ignore = "launches a browser and hits the network"]
async fn creepjs_flags_no_panel() {
let mut d = Detector::open(1, URL).await;
let settled = d.wait_until(READY, Duration::from_secs(240)).await;
if !settled {
let progress = d.eval(PROGRESS).await;
dump("creepjs never settled — progress", &progress);
}
tokio::time::sleep(Duration::from_secs(10)).await;
let report = d.eval(SCRAPE).await;
dump("creepjs", &report);
d.close().await;
let panels = report["panels"].as_array().cloned().unwrap_or_default();
assert!(
!panels.is_empty(),
"read no panels from creepjs — it did not finish rendering, so this run \
proves nothing. See the progress dump above."
);
let flagged = report["flagged"].as_array().cloned().unwrap_or_default();
let names: Vec<String> = flagged
.iter()
.map(|p| format!("{} [{}]", p["panel"].as_str().unwrap_or("?"), p["flags"]))
.collect();
assert!(
flagged.is_empty(),
"creepjs flagged {} panel(s): {}",
flagged.len(),
names.join(", ")
);
}
const LOW_ENTROPY: &str = r#"(() => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = 2;
canvas.height = 2;
context.fillStyle = '#000';
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = '#fff';
context.fillRect(2, 2, 1, 1);
context.beginPath();
context.arc(0, 0, 2, 0, 1, true);
context.closePath();
context.fill();
const value = context.getImageData(0, 0, 2, 2).data.join('');
// An 8x8 read after clearRect must be all zeros, or creepjs documents
// "pixel data modified" outright.
canvas.width = 50; canvas.height = 50;
context.clearRect(0, 0, canvas.width, canvas.height);
const clearedMax = Math.max(...context.getImageData(0, 0, 8, 8).data);
const BLINK = [
'255255255255178178178255246246246255555555255',
'255255255255192192192255240240240255484848255',
'255255255255177177177255246246246255535353255',
'255255255255128128128255191191191255646464255',
'255255255255178178178255247247247255565656255',
'255255255255174174174255242242242255474747255',
'255255255255229229229255127127127255686868255',
'255255255255192192192255244244244255535353255',
];
return JSON.stringify({ value, known: BLINK.includes(value), clearedMax });
})()"#;
#[tokio::test]
#[ignore = "launches a browser"]
async fn canvas_low_entropy_stays_on_the_known_table() {
let mut d = Detector::open(1, "about:blank").await;
let r = d.eval(LOW_ENTROPY).await;
dump("creepjs low-entropy canvas probe", &r);
d.close().await;
assert_eq!(
r["clearedMax"].as_i64(),
Some(0),
"a cleared canvas read back non-zero: creepjs documents this as \
'pixel data modified' on CanvasRenderingContext2D.getImageData"
);
assert_eq!(
r["known"].as_bool(),
Some(true),
"the 2x2 low-entropy canvas read back {}, which is not one of the eight \
values creepjs knows for Blink. That sets LowerEntropy.CANVAS, which \
bold-fails the WebGL panel as well as Canvas 2d.",
r["value"],
);
}
const SPOOF_AUDIT: &str = r#"(async () => {
const out = {};
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
out.hasContext = !!gl;
if (!gl) return JSON.stringify(out);
// The classic probe: these read null until the extension is enabled.
out.beforeExtension = {
vendor: gl.getParameter(37445),
renderer: gl.getParameter(37446),
};
const ext = gl.getExtension('WEBGL_debug_renderer_info');
out.vendor = ext ? gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) : null;
out.renderer = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : null;
// Native shape of the replacement.
const fn = WebGLRenderingContext.prototype.getParameter;
out.toString = Function.prototype.toString.call(fn);
out.ownProps = Object.getOwnPropertyNames(fn).sort().join(',');
out.name = fn.name;
out.hasPrototype = 'prototype' in fn;
try { new fn(); out.constructs = true; } catch (e) { out.constructs = e.constructor.name !== 'TypeError'; }
// Worker scope must report the same card.
out.workerRenderer = await new Promise((resolve) => {
try {
const src = `onmessage = () => {
try {
const c = new OffscreenCanvas(1, 1);
const g = c.getContext('webgl');
const e = g && g.getExtension('WEBGL_debug_renderer_info');
postMessage(e ? g.getParameter(e.UNMASKED_RENDERER_WEBGL) : 'no-ext');
} catch (err) { postMessage('ERR ' + err); }
};`;
const w = new Worker(URL.createObjectURL(new Blob([src], { type: 'text/javascript' })));
w.onmessage = (e) => resolve(e.data);
w.onerror = (e) => resolve('worker-error ' + (e.message || ''));
w.postMessage(1);
setTimeout(() => resolve('timeout'), 8000);
} catch (e) { resolve('ERR ' + e); }
});
return JSON.stringify(out);
})()"#;
#[tokio::test]
#[ignore = "launches a browser"]
async fn webgl_spoof_is_not_detectable() {
let mut d = Detector::open(1, "about:blank").await;
let expected_renderer = d.identity().gpu.webgl_renderer.clone();
let expected_vendor = d.identity().gpu.webgl_vendor.clone();
let r = d.eval_async(SPOOF_AUDIT).await;
dump("webgl spoof audit", &r);
d.close().await;
assert_eq!(r["hasContext"].as_bool(), Some(true), "no WebGL context at all");
assert_eq!(
r["renderer"].as_str(),
Some(expected_renderer.as_str()),
"the page does not report the persona's renderer — the spoof did not apply"
);
assert_eq!(r["vendor"].as_str(), Some(expected_vendor.as_str()));
let worker = r["workerRenderer"].as_str().unwrap_or_default();
if worker == expected_renderer {
println!("worker renderer matches the page");
} else {
println!("UNVERIFIED: worker renderer came back {worker:?}, expected the persona's");
}
assert!(
r["toString"].as_str().is_some_and(|s| s.contains("[native code]")),
"getParameter stringifies to injected source: {}",
r["toString"]
);
assert_eq!(r["ownProps"].as_str(), Some("length,name"));
assert_eq!(r["name"].as_str(), Some("getParameter"));
assert_eq!(r["hasPrototype"].as_bool(), Some(false));
assert_eq!(r["constructs"].as_bool(), Some(false), "getParameter is constructible");
assert!(
r["beforeExtension"]["renderer"].is_null(),
"37446 read back {} before WEBGL_debug_renderer_info was enabled; real \
Chrome returns null, so this hands over the spoof directly",
r["beforeExtension"]["renderer"]
);
}
#[tokio::test]
#[ignore = "launches a browser and hits the network"]
async fn creepjs_headless_ratings_are_zero() {
let mut d = Detector::open(1, URL).await;
d.wait_until(READY, Duration::from_secs(120)).await;
tokio::time::sleep(Duration::from_secs(10)).await;
let report = d.eval(SCRAPE).await;
dump("creepjs headless", &report);
d.close().await;
let headless = report["headless"].as_str().unwrap_or("0");
let stealth = report["stealth"].as_str().unwrap_or("0");
assert_eq!(headless, "0", "creepjs scored {headless}% headless");
assert_eq!(stealth, "0", "creepjs scored {stealth}% stealth");
}