mod common;
use common::{Detector, dump};
use std::time::Duration;
fn url() -> String {
std::env::var("PIXELSCAN_URL").unwrap_or_else(|_| "https://pixelscan.net/fingerprint-check".into())
}
const READY: &str = r#"(() => {
const seen = (window.__net || []).some((n) => String(n.url).includes('/s/api/cbv'));
const t = document.body ? document.body.innerText : '';
return (seen && !/collecting data/i.test(t)) ? 'yes' : 'no';
})()"#;
const SCRAPE: &str = r#"(() => {
const rows = [];
for (const el of document.querySelectorAll('*')) {
if (el.children.length) continue;
const t = (el.textContent || '').trim();
if (!t || t.length > 90) continue;
if (!/inconsisten|consisten|mismatch|detected|spoof|masking|integrity|outdated|version/i.test(t)) continue;
let ctx = el, hops = 0;
while (ctx.parentElement && hops < 3 && (ctx.innerText || '').trim().length < 60) {
ctx = ctx.parentElement; hops++;
}
const line = (ctx.innerText || '').trim().split('\n').map((s) => s.trim()).filter(Boolean).join(' | ');
if (line && !rows.includes(line)) rows.push(line.slice(0, 200));
}
return JSON.stringify({ rows, bodyHead: (document.body.innerText || '').slice(0, 1200) });
})()"#;
const VERSION: &str = r#"(async () => {
const out = { userAgent: navigator.userAgent };
try {
out.secCh = await navigator.userAgentData.getHighEntropyValues(
['architecture', 'bitness', 'brands', 'mobile', 'model', 'platform', 'platformVersion', 'uaFullVersion']);
} catch (e) { out.secChErr = String(e); }
// Engine-side version tells, which no UA rewrite can move.
out.engine = {
// each of these landed in a specific Chromium release
v110_hasSharedStorage: 'sharedStorage' in window,
v117_cssRelativeColor: CSS.supports('color: rgb(from red r g b)'),
v121_hasScrollend: 'onscrollend' in window,
v125_cssAnchor: CSS.supports('anchor-name: --a'),
v128_hasScheduler: 'scheduler' in window && 'yield' in (window.scheduler || {}),
v133_cssIfFunction: CSS.supports('width: if(style(--x: 1): 1px; else: 2px)'),
v140_hasMomentaryPressure: 'PressureObserver' in window,
};
return JSON.stringify(out);
})()"#;
#[tokio::test]
#[ignore = "launches a browser and hits the network"]
async fn fingerprint_is_consistent() {
let mut d = Detector::open(1, &url()).await;
if !d.wait_until(READY, Duration::from_secs(120)).await {
println!("WARNING: pixelscan never settled; it may be blocking the session");
}
tokio::time::sleep(Duration::from_secs(5)).await;
let report = d.eval(SCRAPE).await;
dump("pixelscan rows", &report);
let traffic = d.traffic().await;
dump("traffic", &traffic);
d.close().await;
assert!(
traffic.as_array().is_some_and(|a| a
.iter()
.any(|n| n["url"].as_str().is_some_and(|u| u.contains("/s/api/")))),
"pixelscan's API never answered — the scan did not run, so this proves nothing"
);
let body = report["bodyHead"].as_str().unwrap_or("");
assert!(
!body.to_lowercase().contains("inconsistent"),
"pixelscan called the fingerprint inconsistent; the reason is in the traffic dump above"
);
}
#[tokio::test]
#[ignore = "launches a browser and hits the network"]
async fn browser_version_matches_the_binary() {
let mut d = Detector::open(1, &url()).await;
d.wait_until(READY, Duration::from_secs(120)).await;
let v = d.eval_async(VERSION).await;
dump("version surfaces", &v);
let report = d.eval(SCRAPE).await;
dump("pixelscan rows", &report);
let traffic = d.traffic().await;
dump("traffic", &traffic);
d.close().await;
let ua = v["userAgent"].as_str().unwrap_or_default();
assert!(!ua.is_empty(), "could not read navigator.userAgent — the probe returned {v}");
let ua_major: u32 = ua
.split("Chrome/")
.nth(1)
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let hint_major: u32 = v["secCh"]["uaFullVersion"]
.as_str()
.and_then(|s| s.split('.').next())
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let cbv = traffic
.as_array()
.into_iter()
.flatten()
.find(|n| n["url"].as_str().is_some_and(|u| u.ends_with("/s/api/cbv")))
.and_then(|n| n["res"].as_str())
.and_then(|r| serde_json::from_str::<serde_json::Value>(r).ok());
if let Some(cbv) = cbv {
println!("pixelscan cbv: {}", cbv["value"]);
let v = &cbv["value"];
assert_eq!(
v["legitimate"].as_bool(),
Some(true),
"pixelscan does not recognise the claimed version as a real Chrome release"
);
assert_eq!(
v["majorMatch"].as_bool(),
Some(true),
"pixelscan: the persona claims a Chromium major that is not current \
(it reports latestVersion {}). Bump rustenium's downloader::CHROME_VERSION \
and this catalogue together — claiming the current major on an older \
binary just moves the contradiction to feature detection.",
v["latestVersion"],
);
} else {
println!("WARNING: pixelscan's /s/api/cbv response was not captured");
}
assert!(
ua_major > 0 && hint_major > 0,
"could not parse a version from either surface (UA {ua_major}, hint {hint_major})"
);
println!("UA major {ua_major}, uaFullVersion major {hint_major}");
assert_eq!(
ua_major, hint_major,
"the UA claims Chrome {ua_major} while the uaFullVersion client hint says \
{hint_major}. getHighEntropyValues is read directly by pixelscan's collector."
);
let engine = &v["engine"];
println!("engine feature probes: {engine}");
if ua_major < 140 {
assert_ne!(
engine["v140_hasMomentaryPressure"].as_bool(),
Some(true),
"the persona claims Chrome {ua_major} but the engine has APIs from 140+. \
Update the preset's browser_version to the Chromium being shipped."
);
}
}
const LEGITIMATE_INPUTS: &str = r#"(() => {
const props = [];
for (const k in navigator) props.push(k);
let voices = [];
try {
voices = (speechSynthesis.getVoices() || [])
.filter((v) => v.name && v.name.startsWith('Google '))
.map((v) => v.name);
} catch (e) {}
let fp = { exists: false, features: [], allowed: [] };
try {
const d = document;
fp.exists = !!d.featurePolicy;
if (d.featurePolicy) {
fp.features = d.featurePolicy.features ? d.featurePolicy.features() : [];
fp.allowed = d.featurePolicy.allowedFeatures ? d.featurePolicy.allowedFeatures() : [];
}
fp.permissionsPolicy = !!d.permissionsPolicy;
} catch (e) { fp.err = String(e); }
return JSON.stringify({
featurePolicy: fp,
featurePolicyCount: (fp.features || []).length,
navigatorProps: props.join(),
navigatorPropCount: props.length,
googleVoices: voices.join(),
googleVoiceCount: voices.length,
});
})()"#;
#[tokio::test]
#[ignore = "launches a browser"]
async fn legitimate_hash_inputs() {
let mut d = Detector::open(1, "about:blank").await;
let r = d.eval(LEGITIMATE_INPUTS).await;
d.close().await;
println!("featurePolicy: {}", r["featurePolicy"]);
println!("featurePolicyCount: {}", r["featurePolicyCount"]);
println!("navigator props ({}): {}", r["navigatorPropCount"], r["navigatorProps"]);
println!("google voices ({}): {}", r["googleVoiceCount"], r["googleVoices"]);
}
const BROWSER_SIGNALS: &str = r#"(async () => {
const out = {};
// Media devices: a real desktop reports at least a default audio device.
try {
const devs = await navigator.mediaDevices.enumerateDevices();
out.mediaDevices = devs.map((d) => `${d.kind}:${d.deviceId ? 'id' : 'noid'}:${d.label || '(no label)'}`);
out.mediaDeviceCount = devs.length;
} catch (e) { out.mediaDevicesErr = String(e); }
// Speech voices load asynchronously; wait for them rather than reading zero.
const voices = await new Promise((resolve) => {
let done = false;
const grab = () => {
const v = speechSynthesis.getVoices() || [];
if (v.length && !done) { done = true; resolve(v); }
};
speechSynthesis.onvoiceschanged = grab;
grab();
setTimeout(() => { if (!done) resolve(speechSynthesis.getVoices() || []); }, 5000);
});
out.voiceCount = voices.length;
out.googleVoices = voices.filter((v) => v.name.startsWith('Google ')).length;
out.microsoftVoices = voices.filter((v) => v.name.startsWith('Microsoft ')).length;
out.localVoices = voices.filter((v) => v.localService).length;
// PDF plugin — branded Chrome ships it.
out.pdfViewerEnabled = navigator.pdfViewerEnabled;
out.pluginCount = navigator.plugins.length;
out.pluginNames = [...navigator.plugins].map((p) => p.name);
out.userAgent = navigator.userAgent;
return JSON.stringify(out);
})()"#;
#[tokio::test]
#[ignore = "launches a browser"]
async fn browser_legitimacy_signals() {
let mut d = Detector::open(1, "https://pixelscan.net/fingerprint-check").await;
tokio::time::sleep(Duration::from_secs(8)).await;
let r = d.eval_async(BROWSER_SIGNALS).await;
dump("browser legitimacy signals", &r);
d.close().await;
assert!(
r["mediaDeviceCount"].as_u64().unwrap_or(0) > 0,
"no media devices — pixelscan folds a MEDIA_DEVICES check into the Browser card"
);
assert!(
r["voiceCount"].as_u64().unwrap_or(0) > 0,
"no speech voices — GOOGLE_SPEECH_VOICES and MICROSOFT_SPEECH_VOICES both feed \
the 'not really Chrome' message"
);
assert_eq!(
r["pdfViewerEnabled"].as_bool(),
Some(true),
"pdfViewerEnabled is false — PDF_PLUGIN is one of the five checks"
);
}
#[tokio::test]
#[ignore = "launches a browser and hits the network"]
async fn browser_version_is_not_flagged_outdated() {
let mut d = Detector::open(1, &url()).await;
d.wait_until(READY, Duration::from_secs(150)).await;
tokio::time::sleep(Duration::from_secs(5)).await;
let report = d.eval(SCRAPE).await;
let traffic = d.traffic().await;
d.close().await;
let cbv = traffic
.as_array()
.into_iter()
.flatten()
.find(|n| n["url"].as_str().is_some_and(|u| u.ends_with("/s/api/cbv")))
.and_then(|n| n["res"].as_str())
.and_then(|r| serde_json::from_str::<serde_json::Value>(r).ok());
let cbv = cbv.expect("no /s/api/cbv response — the scan did not run");
println!("cbv: {}", cbv["value"]);
let body = report["bodyHead"].as_str().unwrap_or("").to_lowercase();
let outdated = body.contains("outdated");
println!("page says 'outdated': {outdated}");
assert_eq!(
cbv["value"]["isLatest"].as_bool(),
Some(true),
"isLatest is false, so the page dispatches 'Your browser version is outdated'. \
The persona claims a version behind current stable ({}).",
cbv["value"]["latestVersion"],
);
assert!(!outdated, "the page reported the browser version as outdated");
}
const MEDIA_DEVICES_CHECK: &str = r#"(async () => {
const out = {};
const iframe = document.createElement('iframe');
iframe.id = 'mdId';
iframe.style.display = 'none';
document.body.appendChild(iframe);
await new Promise((r) => setTimeout(r, 500));
try {
const u = await navigator.mediaDevices.enumerateDevices();
const e = await iframe.contentWindow.navigator.mediaDevices.enumerateDevices();
out.topCount = u.length;
out.iframeCount = e.length;
out.topGroupIds = u.map((d) => d.groupId);
out.iframeGroupIds = e.map((d) => d.groupId);
out.topKinds = u.map((d) => d.kind);
if (u.length !== e.length) {
out.result = false; out.reason = 'device counts differ between window and iframe';
} else {
const t = e.map((b) => b.groupId);
const l = u.filter((b) => '' !== b.groupId);
if (l.length === 0) {
out.result = true; out.reason = 'all groupIds empty (no permission) — passes';
} else {
out.result = !l.every((b) => t.indexOf(b.groupId) > -1);
out.reason = out.result ? 'a groupId differs — passes' : 'all groupIds identical — fails';
}
}
} catch (err) {
out.result = false; out.reason = 'threw: ' + err;
}
return JSON.stringify(out);
})()"#;
#[tokio::test]
#[ignore = "launches a browser and hits the network"]
async fn media_devices_check_passes() {
let mut d = Detector::open(1, &url()).await;
d.wait_until(READY, Duration::from_secs(150)).await;
let r = d.eval_async(MEDIA_DEVICES_CHECK).await;
dump("pixelscan MEDIA_DEVICES check", &r);
d.close().await;
assert_eq!(
r["result"].as_bool(),
Some(true),
"MEDIA_DEVICES fails ({}), which turns pixelscan's Browser card red",
r["reason"],
);
}
const CANVAS_NOISE_CHECK: &str = r#"(async () => {
const colours = [[255,0,0],[0,255,0],[0,0,255],[255,255,0],[255,0,255],[0,255,255],
[1,1,1],[254,254,254],[0,0,0],[51,51,51],[102,102,102],[153,153,153],
[204,204,204],[255,255,255]];
const out = { bandPixelCounts: [] };
const once = async (l, b) => {
const o = document.createElement('canvas');
document.body.append(o);
try {
const r = o.toDataURL.toString();
out.toDataURLLength = r.length;
out.toDataURLNative = /(native code)/.test(r);
if (![42, 38].includes(r.length) || !out.toDataURLNative) return null;
Object.assign(o, { width: l * colours.length, height: b });
const i = o.getContext('2d');
colours.forEach((M, T) => {
i.fillStyle = '#' + M.map((x) => x.toString(16).padStart(2, '0')).join('');
i.fillRect(l * T, 0, l * (1 + T), b);
});
const h = o.toDataURL();
const m = document.createElement('img');
await new Promise((res) => { m.onload = () => res(); m.src = h; });
const v = document.createElement('canvas');
Object.assign(v, { width: l * colours.length, height: b });
const w = v.getContext('2d');
w.drawImage(m, 0, 0);
const ok = colours.map((M, T) => {
const I = w.getImageData(l * T, 0, l, b).data;
const z = new Uint32Array(I.buffer);
const V = new Map();
z.forEach((S) => V.set(S, (V.get(S) || 0) + 1));
const W = new Uint32Array(new Uint8Array([...M, 255]).buffer)[0];
const n = V.has(W) ? V.get(W) : 0;
if (out.bandPixelCounts.length < colours.length) out.bandPixelCounts.push(n);
return n === l * b;
}).every(Boolean);
return ok ? h : null;
} finally { document.body.removeChild(o); }
};
try {
const [t, l2] = await Promise.all([once(5, 5), once(5, 5)]);
out.firstRunOk = !!t;
out.secondRunOk = !!l2;
out.identical = !!(t && l2 && t === l2);
out.result = !!(t && l2 && t === l2);
} catch (e) { out.result = false; out.err = String(e); }
out.expectedPerBand = 25;
return JSON.stringify(out);
})()"#;
#[tokio::test]
#[ignore = "launches a browser"]
async fn canvas_noise_check_passes() {
let mut d = Detector::open(1, "about:blank").await;
let r = d.eval_async(CANVAS_NOISE_CHECK).await;
dump("pixelscan CANVAS_NOISE check", &r);
d.close().await;
assert_eq!(
r["result"].as_bool(),
Some(true),
"CANVAS_NOISE fails. It is non-additional, so pixelscan sets p=false and \
rewrites the browser version to 'N or below' in red. Per-band pixel counts \
were {} (each should be 25).",
r["bandPixelCounts"],
);
}