mirror of
https://github.com/feder-cr/invisible_playwright.git
synced 2026-06-19 09:08:06 +02:00
Pin firefox-12: cross-OS render parity + always-present standard fonts
- BINARY_VERSION -> firefox-12 (self-calibrating font widths, per-family canvas distinctness, render-noise that preserves solid reference renders). - font_pool: the standard Windows fonts (Calibri, Franklin Gothic, Gadugi, Javanese Text, Myanmar Text) move from the per-profile optional set to core, so they are always present and the detected font set matches a real Windows install on every host. Defensive dedup in derive_font_prefs. - GPU persona applied on every platform (Linux/macOS present a coherent Windows GPU + WebGL params); pool re-rooted on a real-device GPU mix; render seeds recalibrated. - prefs: emit absolute per-family font widths that the binary self-calibrates. - geoip: always pull the latest mmdb via the releases/latest permalink, checked each launch, offline-safe (no pinned tag that can 404). - tests: per-font canvas distinctness, solid-readback purity under render-noise, always-present standard-font invariant, no duplicate families.
This commit is contained in:
parent
6dcdc42c05
commit
8f4b20a19d
15 changed files with 1885 additions and 430 deletions
125
tests/test_canvas_render_stealth.py
Normal file
125
tests/test_canvas_render_stealth.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Canvas / WebGL render-stealth regression tests (binary-level, 2026-06-18).
|
||||
|
||||
Two patched-binary behaviours that must never regress, both needed for the
|
||||
fingerprint to look like a real Windows browser to FOSS detectors (CreepJS,
|
||||
FingerprintJS, BrowserLeaks) and to image-dedup font probes / fixed-hash
|
||||
reference checks:
|
||||
|
||||
1. Per-font canvas distinctness — whitelisted named fonts are backed by the
|
||||
host list-head glyphs (so measureText widths are host-independent), but each
|
||||
must still rasterise to a DISTINCT image at tiny probe sizes. Otherwise an
|
||||
image-dedup font probe collapses them to ~1 name and the reported font set
|
||||
looks fabricated. (C++: per-font sub-pixel draw offset in DrawText.)
|
||||
2. Solid WebGL readback purity under render-noise — a fixed solid-colour WebGL
|
||||
readback (which reference checks hash against a universal constant) must stay
|
||||
byte-exact even with per-seed render-noise enabled, while high-entropy
|
||||
renders stay noised. (C++: render-noise skips near-uniform WebGL readbacks.)
|
||||
|
||||
Runs against about:blank, no network/proxy. Part of the e2e release gate.
|
||||
Run: pytest tests/test_canvas_render_stealth.py -m e2e -v
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
from invisible_playwright import prefs as _prefs
|
||||
from invisible_playwright._fpforge import generate_profile
|
||||
|
||||
# Diverse-codepoint probe string — maximises per-font rendering differences, the
|
||||
# way an image-dedup font probe drives a tiny canvas.
|
||||
_PROBE = ("\U0001f6cd1>'`amlρiюदे來˦"
|
||||
"\U00025578に◌\U0002003eԩԨ")
|
||||
|
||||
|
||||
def _named_fonts(limit: int = 30) -> list[str]:
|
||||
"""The whitelisted NAMED fonts (absolute collapse-target width >= 10) for the
|
||||
test seed — these are the ones the per-font offset must keep distinct."""
|
||||
prof = generate_profile(42)
|
||||
metrics = _prefs._font_metrics_for_platform(prof._raw.get("font_metrics", "") or "")
|
||||
out: list[str] = []
|
||||
for ent in metrics.split(","):
|
||||
name, _, val = ent.partition("|")
|
||||
if not val:
|
||||
continue
|
||||
try:
|
||||
if float(val.replace("px", "")) >= 10.0:
|
||||
out.append(name)
|
||||
except ValueError:
|
||||
pass
|
||||
return out[:limit]
|
||||
|
||||
|
||||
_FONTS = _named_fonts()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def noised_page(firefox_binary):
|
||||
"""Headless session with render-noise explicitly ON (positive hw_seed) so the
|
||||
purity / distinctness guards actually exercise the noise path."""
|
||||
with InvisiblePlaywright(
|
||||
seed=42,
|
||||
binary_path=firefox_binary,
|
||||
headless=True,
|
||||
extra_prefs={"zoom.stealth.fpp.hw_seed": 24680},
|
||||
) as browser:
|
||||
p = browser.new_context().new_page()
|
||||
p.goto("about:blank", timeout=30_000)
|
||||
yield p
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_named_fonts_render_distinct_canvas_images(noised_page):
|
||||
"""Each whitelisted named font must produce a DISTINCT tiny-canvas image so an
|
||||
image-dedup font probe keeps every name. Regression: without the per-font draw
|
||||
offset all whitelisted fonts share the list-head glyphs -> ~1-2 distinct
|
||||
images -> degenerate detected-font set."""
|
||||
assert len(_FONTS) >= 10, "expected a non-trivial named-font whitelist to probe"
|
||||
distinct = noised_page.evaluate(
|
||||
"""(args) => {
|
||||
const [fonts, V] = args;
|
||||
const c = document.createElement('canvas'); c.width = 90; c.height = 12;
|
||||
const d = c.getContext('2d'); d.fillStyle = 'red';
|
||||
const seen = new Set();
|
||||
for (const f of fonts) {
|
||||
d.clearRect(0, 0, 90, 12);
|
||||
d.font = 'normal 4px "' + f + '"';
|
||||
d.fillText(V, 5, 8);
|
||||
seen.add(c.toDataURL());
|
||||
}
|
||||
return seen.size;
|
||||
}""",
|
||||
[_FONTS, _PROBE],
|
||||
)
|
||||
# broken (offset removed) collapses to ~1-2; require nearly all distinct.
|
||||
assert distinct >= len(_FONTS) - 2, \
|
||||
f"only {distinct}/{len(_FONTS)} distinct font images (per-font offset regressed?)"
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_solid_webgl_readback_stays_pure_under_noise(noised_page):
|
||||
"""A solid-colour WebGL readback must remain byte-exact (only {0,255}) with
|
||||
render-noise on. Regression: the noise drifted edge pixels 255->254 on some GL
|
||||
backends (Linux ANGLE-over-GL), breaking fixed-hash reference checks ('oe')."""
|
||||
res = noised_page.evaluate(
|
||||
"""() => {
|
||||
const c = document.createElement('canvas'); c.width = 256; c.height = 24;
|
||||
const gl = c.getContext('webgl', {preserveDrawingBuffer: true});
|
||||
if (!gl) return {ok: false, reason: 'no-webgl'};
|
||||
gl.clearColor(1, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
const buf = new Uint8Array(256 * 24 * 4);
|
||||
gl.finish(); gl.readPixels(0, 0, 256, 24, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||||
const vals = new Set();
|
||||
for (let i = 0; i < buf.length; i++) vals.add(buf[i]);
|
||||
return {ok: true, vals: Array.from(vals).sort((a, b) => a - b)};
|
||||
}"""
|
||||
)
|
||||
if not res["ok"]:
|
||||
pytest.skip(res.get("reason", "webgl unavailable"))
|
||||
assert res["vals"] == [0, 255], \
|
||||
f"solid WebGL readback not pure under noise: values {res['vals']} (uniform-skip regressed?)"
|
||||
|
||||
|
||||
# NOTE: "high-entropy WebGL still noised" is covered by test_webgl_noise_active.py
|
||||
# (kept separate: it launches its own browsers, so it must not run while this
|
||||
# module's shared `noised_page` browser is open — the sync API cannot nest).
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
"""Unit tests for the intelligent geoip mmdb auto-update in `download.py`.
|
||||
"""Unit tests for the geoip mmdb auto-update in `download.py`.
|
||||
|
||||
daijro/geoip-all-in-one rebuilds weekly; `ensure_geoip_mmdb` keeps the cache
|
||||
fresh without a download (or API call) on every launch. These tests mock the
|
||||
cache root, the latest-tag API, and the per-tag download so nothing touches the
|
||||
network.
|
||||
daijro/geoip-all-in-one rebuilds weekly and keeps only the latest ~2 releases,
|
||||
so `ensure_geoip_mmdb` never pins a tag: on every call it resolves the CURRENT
|
||||
latest tag (from the `releases/latest/download` permalink, no GitHub API) and
|
||||
downloads it only when it differs from the cache. These tests mock the cache
|
||||
root, the tag resolver, and the per-tag download so nothing touches the network.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import invisible_playwright.download as dl
|
||||
|
|
@ -29,14 +27,6 @@ def _make_cached(root, tag, name=dl.GEOIP_MMDB_NAME):
|
|||
return f
|
||||
|
||||
|
||||
def _set_marker_age(root, days):
|
||||
m = root / "geoip" / ".last_check"
|
||||
m.parent.mkdir(parents=True, exist_ok=True)
|
||||
m.touch()
|
||||
old = time.time() - days * 86400
|
||||
os.utime(m, (old, old))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# env override
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -56,76 +46,97 @@ def test_env_override_missing_raises(tmp_path, monkeypatch):
|
|||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# freshness window
|
||||
# every-launch latest check
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.unit
|
||||
def test_fresh_cache_no_network(cache, monkeypatch):
|
||||
f = _make_cached(cache, "2026.06.03")
|
||||
_set_marker_age(cache, 0) # just checked
|
||||
|
||||
def boom():
|
||||
raise AssertionError("latest-tag API must NOT be called within the window")
|
||||
|
||||
monkeypatch.setattr(dl, "_latest_geoip_tag", boom)
|
||||
assert dl.ensure_geoip_mmdb(max_age_days=7) == f
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_stale_same_tag_no_download(cache, monkeypatch):
|
||||
f = _make_cached(cache, "2026.06.03")
|
||||
_set_marker_age(cache, 30) # stale → will re-check
|
||||
monkeypatch.setattr(dl, "_latest_geoip_tag", lambda: "2026.06.03")
|
||||
# real _download_geoip_tag runs but target exists, so no actual download:
|
||||
def test_cache_is_latest_no_download(cache, monkeypatch):
|
||||
f = _make_cached(cache, "2026.06.17")
|
||||
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: "2026.06.17")
|
||||
monkeypatch.setattr(dl, "_download_file", lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("must not download when tag already cached")))
|
||||
assert dl.ensure_geoip_mmdb(max_age_days=7) == f
|
||||
AssertionError("must not download when cache already on the latest tag")))
|
||||
assert dl.ensure_geoip_mmdb() == f
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_stale_new_tag_downloads_and_prunes(cache, monkeypatch):
|
||||
old = _make_cached(cache, "2026.06.03")
|
||||
_set_marker_age(cache, 30)
|
||||
monkeypatch.setattr(dl, "_latest_geoip_tag", lambda: "2026.06.10")
|
||||
|
||||
def fake_download(tag):
|
||||
return _make_cached(cache, tag) # simulate fetch+extract of the new tag
|
||||
|
||||
monkeypatch.setattr(dl, "_download_geoip_tag", fake_download)
|
||||
got = dl.ensure_geoip_mmdb(max_age_days=7)
|
||||
assert got.parent.name == "2026.06.10"
|
||||
def test_new_tag_downloads_and_prunes(cache, monkeypatch):
|
||||
old = _make_cached(cache, "2026.06.10")
|
||||
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: "2026.06.17")
|
||||
monkeypatch.setattr(dl, "_download_geoip_tag", lambda tag: _make_cached(cache, tag))
|
||||
got = dl.ensure_geoip_mmdb()
|
||||
assert got.parent.name == "2026.06.17"
|
||||
assert not old.parent.exists() # old tag pruned
|
||||
assert got.exists()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# offline resilience
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.unit
|
||||
def test_api_down_with_cache_uses_cache(cache, monkeypatch):
|
||||
f = _make_cached(cache, "2026.06.03")
|
||||
_set_marker_age(cache, 30)
|
||||
|
||||
def boom():
|
||||
raise OSError("offline")
|
||||
|
||||
monkeypatch.setattr(dl, "_latest_geoip_tag", boom)
|
||||
assert dl.ensure_geoip_mmdb(max_age_days=7) == f # stale cache reused, no raise
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cold_cache_api_down_falls_back_to_pinned(cache, monkeypatch):
|
||||
# no cache at all + API unreachable → pinned GEOIP_MMDB_VERSION fallback.
|
||||
def boom():
|
||||
raise OSError("offline")
|
||||
|
||||
monkeypatch.setattr(dl, "_latest_geoip_tag", boom)
|
||||
captured = {}
|
||||
|
||||
def fake_download(tag):
|
||||
captured["tag"] = tag
|
||||
return _make_cached(cache, tag)
|
||||
|
||||
monkeypatch.setattr(dl, "_download_geoip_tag", fake_download)
|
||||
got = dl.ensure_geoip_mmdb(max_age_days=7)
|
||||
assert captured["tag"] == dl.GEOIP_MMDB_VERSION
|
||||
def test_cold_cache_downloads_latest(cache, monkeypatch):
|
||||
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: "2026.06.17")
|
||||
monkeypatch.setattr(dl, "_download_geoip_tag", lambda tag: _make_cached(cache, tag))
|
||||
got = dl.ensure_geoip_mmdb()
|
||||
assert got.parent.name == "2026.06.17"
|
||||
assert got.exists()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# offline resilience (no pinned-tag fallback — the pin rots and 404s)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.unit
|
||||
def test_offline_with_cache_uses_cache(cache, monkeypatch):
|
||||
f = _make_cached(cache, "2026.06.10")
|
||||
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: None) # offline
|
||||
monkeypatch.setattr(dl, "_download_file", lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("offline → must not attempt a download")))
|
||||
assert dl.ensure_geoip_mmdb() == f # cache reused, no raise
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cold_cache_offline_raises(cache, monkeypatch):
|
||||
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: None) # offline
|
||||
with pytest.raises(RuntimeError):
|
||||
dl.ensure_geoip_mmdb()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_download_failure_with_cache_falls_back(cache, monkeypatch):
|
||||
f = _make_cached(cache, "2026.06.10")
|
||||
monkeypatch.setattr(dl, "_resolve_latest_geoip_tag", lambda: "2026.06.17")
|
||||
|
||||
def boom(tag):
|
||||
raise OSError("transient download failure")
|
||||
|
||||
monkeypatch.setattr(dl, "_download_geoip_tag", boom)
|
||||
assert dl.ensure_geoip_mmdb() == f # keeps the old cache rather than failing
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# latest-tag resolution via the permalink 302 (no GitHub API)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.unit
|
||||
def test_resolve_tag_from_permalink_redirect(monkeypatch):
|
||||
class _Resp:
|
||||
headers = {"Location":
|
||||
"https://github.com/daijro/geoip-all-in-one/releases/download/"
|
||||
"2026.06.17/geoip-aio-all.mmdb.zip"}
|
||||
|
||||
monkeypatch.setattr(dl.requests, "head", lambda *a, **k: _Resp())
|
||||
assert dl._resolve_latest_geoip_tag() == "2026.06.17"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_resolve_tag_permalink_fails_falls_back_to_api(monkeypatch):
|
||||
def head_boom(*a, **k):
|
||||
raise OSError("no network for HEAD")
|
||||
|
||||
monkeypatch.setattr(dl.requests, "head", head_boom)
|
||||
monkeypatch.setattr(dl, "_latest_geoip_tag_api", lambda: "2026.06.17")
|
||||
assert dl._resolve_latest_geoip_tag() == "2026.06.17"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_resolve_tag_all_fail_returns_none(monkeypatch):
|
||||
def boom(*a, **k):
|
||||
raise OSError("offline")
|
||||
|
||||
monkeypatch.setattr(dl.requests, "head", boom)
|
||||
monkeypatch.setattr(dl, "_latest_geoip_tag_api", boom)
|
||||
assert dl._resolve_latest_geoip_tag() is None
|
||||
|
|
|
|||
|
|
@ -306,9 +306,9 @@ def test_windows_virtual_display_with_socks_proxy(monkeypatch):
|
|||
@pytest.mark.integration
|
||||
def test_linux_xvfb_workarounds_with_socks_proxy(monkeypatch):
|
||||
"""IT11 — Linux + SOCKS5 proxy: Xvfb workarounds applied, GPU renderer
|
||||
spoofed from profile, SOCKS keys written. virtual_display is a Windows-
|
||||
only concept so we omit it here; passing ``virtual_display=True`` on
|
||||
Linux must NOT set ``security.sandbox.gpu.level`` (covered by VD3)."""
|
||||
spoofed from the validated WebGL persona, SOCKS keys written. virtual_display
|
||||
is a Windows-only concept so we omit it here; passing ``virtual_display=True``
|
||||
on Linux must NOT set ``security.sandbox.gpu.level`` (covered by VD3)."""
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
profile = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(profile, virtual_display=True)
|
||||
|
|
@ -323,9 +323,15 @@ def test_linux_xvfb_workarounds_with_socks_proxy(monkeypatch):
|
|||
assert prefs["webgl.force-enabled"] is True
|
||||
# Windows-only sandbox key absent on Linux even with virtual_display=True.
|
||||
assert "security.sandbox.gpu.level" not in prefs
|
||||
# GPU renderer is spoofed from the profile (not cleared like on Windows).
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == profile.gpu.renderer
|
||||
# GPU renderer is spoofed from the validated WebGL persona (a coherent Windows
|
||||
# ANGLE GPU whose renderer + params cross-check), applied on every host — NOT the
|
||||
# raw profile.gpu.renderer, which has no coherent param set and is never exposed.
|
||||
from invisible_playwright._webgl_personas import select_persona
|
||||
_persona = select_persona(profile.seed)
|
||||
assert _persona, "expected a validated persona for this seed"
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == _persona["prefs"]["zoom.stealth.webgl.renderer"]
|
||||
assert prefs["zoom.stealth.webgl.renderer"] # non-empty
|
||||
assert "ANGLE" in prefs["zoom.stealth.webgl.renderer"] # Windows ANGLE form
|
||||
# SOCKS branch wrote its keys without clobbering the Linux prefs above.
|
||||
assert prefs["network.proxy.type"] == 1
|
||||
assert prefs["network.proxy.socks"] == "127.0.0.1"
|
||||
|
|
|
|||
|
|
@ -15,18 +15,18 @@ from invisible_playwright.prefs import (
|
|||
|
||||
@pytest.mark.unit
|
||||
def test_translate_includes_gpu_renderer_windows(monkeypatch):
|
||||
"""On Windows we falsify the GPU to one of the calibrated CLEAN buckets (FP Pro
|
||||
tampering_ml<=0.5 on every seed; sweep 2026-06-14). Only Radeon R9 200 Series and
|
||||
Intel Arc A750 ship — every NVIDIA/iGPU/945 bucket is penalized. See _webgl_personas."""
|
||||
"""On Windows we falsify the GPU to a real-Firefox GPU from the camoufox-derived pool
|
||||
(prevalence-weighted; full coherent renderer+vendor+params+extensions). The chosen GPU's
|
||||
renderer/vendor are applied verbatim and the renderer is in ANGLE D3D11 wire format."""
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
_CLEAN = {
|
||||
"ANGLE (AMD, AMD Radeon R9 200 Series Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
"ANGLE (Intel, Intel(R) Arc(TM) A750 Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)",
|
||||
}
|
||||
from invisible_playwright._webgl_personas import select_persona
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.renderer"] in _CLEAN
|
||||
assert prefs["zoom.stealth.webgl.vendor"] in {"Google Inc. (AMD)", "Google Inc. (Intel)"}
|
||||
persona = select_persona(42)
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == persona["renderer"]
|
||||
assert prefs["zoom.stealth.webgl.renderer"].endswith(", D3D11)")
|
||||
assert prefs["zoom.stealth.webgl.vendor"] == persona["vendor"]
|
||||
assert "Google Inc." in prefs["zoom.stealth.webgl.vendor"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
@ -156,15 +156,17 @@ def test_canvas_noise_mask_windows_uses_intel_path(monkeypatch):
|
|||
|
||||
@pytest.mark.unit
|
||||
def test_webgl_extensions_persona_on_windows(monkeypatch):
|
||||
# WE2: with a persona active on Windows, extensions are FORCED to the persona's native-order
|
||||
# list (host-independent), NOT cleared. Order is load-bearing (must match the persona verbatim).
|
||||
# WE2: with a persona active on Windows, the webgl1/webgl2 extension lists are FORCED to
|
||||
# the chosen GPU's real native-order lists (carried in the persona's coherent `prefs`),
|
||||
# NOT cleared. Order is load-bearing (must match the GPU's real capture verbatim).
|
||||
monkeypatch.setattr(sys, "platform", "win32")
|
||||
from invisible_playwright._webgl_personas import select_persona
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
persona = select_persona(42)
|
||||
assert prefs["zoom.stealth.webgl.extensions"] == persona["ext1"]
|
||||
assert prefs["zoom.stealth.webgl2.extensions"] == persona["ext2"]
|
||||
assert prefs["zoom.stealth.webgl.extensions"] == persona["prefs"]["zoom.stealth.webgl.extensions"]
|
||||
assert prefs["zoom.stealth.webgl2.extensions"] == persona["prefs"]["zoom.stealth.webgl2.extensions"]
|
||||
assert prefs["zoom.stealth.webgl.extensions"] # non-empty (a real GPU's ext list)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -413,14 +415,18 @@ def test_font_metrics_linux2_variant_uses_linux_branch(monkeypatch):
|
|||
|
||||
@pytest.mark.unit
|
||||
def test_gpu_renderer_set_from_profile_on_linux(monkeypatch):
|
||||
# PG1: on Linux we spoof to the profile's Windows-ANGLE renderer
|
||||
# string so cross-platform sessions present a consistent Windows GPU.
|
||||
# PG1: on Linux (as on EVERY host) we apply the camoufox-derived Windows-ANGLE GPU persona,
|
||||
# so the page sees a consistent Windows GPU (rule: always look Windows). The C++ WebGL
|
||||
# override is platform-independent (SanitizeRenderer is pure string regex), so the same
|
||||
# persona renderer/vendor is presented on Linux too — no more "Generic Renderer".
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
from invisible_playwright._webgl_personas import select_persona
|
||||
p = generate_profile(seed=42)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == p.gpu.renderer
|
||||
assert prefs["zoom.stealth.webgl.vendor"] == p.gpu.vendor
|
||||
assert prefs["zoom.stealth.webgl.renderer"] # non-empty
|
||||
persona = select_persona(42)
|
||||
assert prefs["zoom.stealth.webgl.renderer"] == persona["renderer"]
|
||||
assert prefs["zoom.stealth.webgl.renderer"].endswith(", D3D11)")
|
||||
assert prefs["zoom.stealth.webgl.vendor"] == persona["vendor"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
@ -465,8 +471,10 @@ def test_canvas_noise_mask_intel_on_linux(monkeypatch):
|
|||
|
||||
@pytest.mark.unit
|
||||
def test_canvas_noise_mask_nvidia_on_linux(monkeypatch):
|
||||
# CN2: NVIDIA/AMD renderer → 1/8 noise (mask=7). The "intel" substring
|
||||
# check must NOT match here.
|
||||
# CN2: the canvas-noise mask follows the REAL HOST GPU (the canvas is drawn by real
|
||||
# hardware, NOT the exposed persona), so it is the Intel-class 1/16 rate (mask=15) on the
|
||||
# dev/test host even when an NVIDIA persona is exposed — the persona vendor does NOT drive
|
||||
# the noise rate anymore (would over-noise on an Intel host).
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
p = generate_profile(
|
||||
seed=42,
|
||||
|
|
@ -476,7 +484,7 @@ def test_canvas_noise_mask_nvidia_on_linux(monkeypatch):
|
|||
},
|
||||
)
|
||||
prefs = translate_profile_to_prefs(p)
|
||||
assert prefs["zoom.stealth.canvas.noise_skip_mask"] == 7
|
||||
assert prefs["zoom.stealth.canvas.noise_skip_mask"] == 15
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
|
|
|
|||
|
|
@ -275,6 +275,80 @@ def test_derive_font_whitelist_legacy_shim_matches_dict_form():
|
|||
derive_font_prefs("low_end", rng_b)["whitelist"]
|
||||
|
||||
|
||||
# Standard fonts that ship with every Windows 10/11 install. They MUST be in the
|
||||
# core (always-present) set, never in the optional/per-profile set: a real Windows
|
||||
# machine never lacks them, so a session that drops one advertises a font set that
|
||||
# doesn't match any real Windows profile (image-dedup font probes then report a
|
||||
# short/degenerate name list → server-side OS-font-set checks fail). Calibri in
|
||||
# particular sat in `optional` (a bug); these five caused the detected set to come
|
||||
# up short on some seeds. Regression guard for the 2026-06-18 optional→core move.
|
||||
_STANDARD_WINDOWS_FONTS = [
|
||||
"calibri", "franklin gothic", "gadugi", "javanese text", "myanmar text",
|
||||
]
|
||||
_ALL_GPU_CLASSES = [
|
||||
"integrated_old", "integrated_modern", "mid_range", "high_end",
|
||||
"low_end", "workstation",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("gpu_class", _ALL_GPU_CLASSES)
|
||||
def test_standard_windows_fonts_always_present_every_class_and_seed(gpu_class):
|
||||
"""FP7 [regression]: the standard-Windows fonts appear in BOTH whitelist and
|
||||
metrics for every gpu_class across many seeds (i.e. they are core, not
|
||||
profile-optional). Guards against a standard font silently becoming optional."""
|
||||
for seed in range(40):
|
||||
out = derive_font_prefs(gpu_class, random.Random(seed))
|
||||
wl = set(out["whitelist"].split(","))
|
||||
metrics_names = {s.split("|", 1)[0] for s in out["metrics"].split(",")}
|
||||
for font in _STANDARD_WINDOWS_FONTS:
|
||||
assert font in wl, f"{font!r} missing from whitelist (class={gpu_class}, seed={seed})"
|
||||
assert font in metrics_names, f"{font!r} missing from metrics (class={gpu_class}, seed={seed})"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_standard_windows_fonts_are_in_core_pool():
|
||||
"""FP8 [regression]: the standard-Windows fonts live in the CORE pool (not
|
||||
optional) — the structural source of the always-present guarantee above."""
|
||||
core_names = {e["name"] for e in _sampler._FONT_CORE}
|
||||
optional_names = {e["name"] for e in _sampler._FONT_OPTIONAL}
|
||||
for font in _STANDARD_WINDOWS_FONTS:
|
||||
assert font in core_names, f"{font!r} must be in core pool"
|
||||
assert font not in optional_names, f"{font!r} must NOT be in optional pool"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("gpu_class", _ALL_GPU_CLASSES)
|
||||
def test_derive_font_prefs_no_duplicate_families(gpu_class):
|
||||
"""FP9 [regression]: no family appears twice in whitelist/metrics, even when a
|
||||
profile's optional list also names a core font. Guards the dedup in
|
||||
derive_font_prefs (a duplicate family would emit a malformed pref pair)."""
|
||||
for seed in range(30):
|
||||
out = derive_font_prefs(gpu_class, random.Random(seed))
|
||||
wl = out["whitelist"].split(",")
|
||||
metrics_names = [s.split("|", 1)[0] for s in out["metrics"].split(",")]
|
||||
assert len(wl) == len(set(wl)), f"duplicate in whitelist (class={gpu_class}, seed={seed})"
|
||||
assert len(metrics_names) == len(set(metrics_names)), \
|
||||
f"duplicate in metrics (class={gpu_class}, seed={seed})"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.parametrize("gpu_class", _ALL_GPU_CLASSES)
|
||||
def test_derive_font_prefs_named_fonts_emit_absolute_widths(gpu_class):
|
||||
"""FP10 [regression]: every emitted metrics value is a positive number; named
|
||||
(non-generic) fonts carry an ABSOLUTE collapse-target width (>= 10), which the
|
||||
binary self-calibrates per host. A value < 10 here would mean a font slipped
|
||||
through as a bare multiplicative factor and would render at the wrong width."""
|
||||
out = derive_font_prefs(gpu_class, random.Random(3))
|
||||
for entry in out["metrics"].split(","):
|
||||
name, _, val = entry.partition("|")
|
||||
v = float(val.replace("px", ""))
|
||||
assert v > 0.0, f"non-positive metrics value for {name!r}"
|
||||
# the standard named fonts must be absolute (collapse-target) widths
|
||||
if name in _STANDARD_WINDOWS_FONTS:
|
||||
assert v >= 10.0, f"{name!r} emitted as factor {v} (<10), expected absolute width"
|
||||
|
||||
|
||||
# ── Forge / sample ──────────────────────────────────────────────────────
|
||||
|
||||
# Keys the Forge.sample bundle must always contain. Builds on _LOCKED +
|
||||
|
|
|
|||
63
tests/test_webgl_noise_active.py
Normal file
63
tests/test_webgl_noise_active.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Regression guard: render-noise stays ACTIVE on high-entropy WebGL readbacks.
|
||||
|
||||
The near-uniform skip added 2026-06-18 (so fixed-hash reference checks on a solid
|
||||
WebGL readback pass) must NOT disable noise on real fingerprint renders. A
|
||||
high-entropy WebGL readback (>16 distinct colours) must still differ between two
|
||||
seeds — i.e. the per-seed gamma noise is applied. Pairs with
|
||||
test_canvas_render_stealth.py (solid readback stays pure).
|
||||
|
||||
Kept in its own file: it launches its own short-lived browsers, so it must not run
|
||||
alongside another module's shared browser (the Playwright sync API cannot nest).
|
||||
|
||||
Run: pytest tests/test_webgl_noise_active.py -m e2e -v
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from invisible_playwright import InvisiblePlaywright
|
||||
|
||||
# A high-entropy render: 64 columns, each a distinct colour (>16 distinct → the
|
||||
# near-uniform skip does NOT apply → noise must run).
|
||||
_HIGH_ENTROPY_JS = """() => {
|
||||
const c = document.createElement('canvas'); c.width = 64; c.height = 64;
|
||||
const gl = c.getContext('webgl', {preserveDrawingBuffer: true});
|
||||
if (!gl) return null;
|
||||
gl.enable(gl.SCISSOR_TEST);
|
||||
for (let k = 0; k < 64; k++) {
|
||||
gl.scissor(k, 0, 1, 64);
|
||||
gl.clearColor(k / 64, (63 - k) / 64, (k * 7 % 64) / 64, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
}
|
||||
const buf = new Uint8Array(64 * 64 * 4);
|
||||
gl.finish(); gl.readPixels(0, 0, 64, 64, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||||
return Array.from(buf);
|
||||
}"""
|
||||
|
||||
|
||||
def _render_hash(firefox_binary, seed: int):
|
||||
with InvisiblePlaywright(
|
||||
seed=seed, binary_path=firefox_binary, headless=True,
|
||||
extra_prefs={"zoom.stealth.fpp.hw_seed": 1000 + seed},
|
||||
) as b:
|
||||
p = b.new_context().new_page()
|
||||
p.goto("about:blank", timeout=30_000)
|
||||
arr = p.evaluate(_HIGH_ENTROPY_JS)
|
||||
if arr is None:
|
||||
return None
|
||||
return hashlib.sha256(bytes(arr)).hexdigest()
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
def test_high_entropy_webgl_still_noised_per_seed(firefox_binary):
|
||||
"""Two different seeds → two different per-seed gamma curves → the high-entropy
|
||||
readback hashes must differ. Identical hashes would mean the noise was skipped
|
||||
on a real (non-uniform) render — a regression of the uniform-skip scope."""
|
||||
h1 = _render_hash(firefox_binary, 1)
|
||||
h2 = _render_hash(firefox_binary, 2)
|
||||
if h1 is None or h2 is None:
|
||||
pytest.skip("webgl unavailable")
|
||||
assert h1 != h2, \
|
||||
"high-entropy WebGL readback identical across seeds → render-noise not applied"
|
||||
Loading…
Add table
Add a link
Reference in a new issue