mirror of
https://github.com/daijro/camoufox.git
synced 2026-09-09 08:01:15 +00:00
fix(fingerprint): keep the WebGL renderer coherent with the screen (#729)
BrowserForge picks navigator/screen; the GPU is drawn separately from webgl_data.db weighted only by OS. Nothing ties the two together, so the synthetic path emits pairs no real machine ships -- a discrete desktop GPU behind a 1024x600 panel. Consistency checks (Pixelscan, Fingerprint.com) read that as masking even though every individual value is plausible on its own. Builds on @dyiapanis's #730, which identified the problem and the GPU-class thresholds, with three changes: * Constrain the GPU to the screen rather than the screen to the GPU. sample_webgl_for_screen does rejection sampling, so the GPU keeps webgl_data.db's real OS-weighted distribution and the geometry -- already reconciled against the real display and the window box by clamp_screen_to_display / fix_screen_no_taskbar / clamp_window_dimensions / clamp_window_position -- is left alone. * Where no coherent GPU exists at all (BrowserForge still carries netbook-era geometry, and nothing in the pool drives a sub-1366x768 panel), raise_screen_to_gpu_floor lifts the screen instead. It measures the screen-to-avail gap BEFORE mutating -- #730 computed it after overwriting screen.height, which turned a 1024x600 -> 1080 bump into a 520px "taskbar", a fresh impossible-geometry tell -- and it runs BEFORE clamp_screen_to_display so a genuinely small monitor still wins and a headful window cannot be pushed back off its own display (#499). * No Apple-M Retina floor. Apple silicon also ships in the Mac mini and Mac Studio, which drive whatever external monitor is attached, so pinning it to 2560x1600 would reject real hardware and shrink the pool for nothing. Measured over 300 synthetic fingerprints, incoherent GPU/screen pairs fall from 54.3% to 0%, with avail <= screen and availHeight < height holding in every trial. The screen floor is a no-op for the Linux and Windows pools (0/400 draws below it) and fires on 3.5% of macOS draws, so the entropy cost is confined to the implausible tail it exists to remove. Co-authored-by: D Yiapanis <d@yiapanis.co>
This commit is contained in:
co-authored by
D Yiapanis
parent
160c806ad1
commit
d6a806e2b5
@@ -4,7 +4,7 @@ import re
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from random import choice, randint, randrange, random, sample, shuffle
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, FrozenSet, List, Optional, Tuple
|
||||
|
||||
from browserforge.fingerprints import (
|
||||
Fingerprint,
|
||||
@@ -470,6 +470,201 @@ def set_media_devices_defaults(config: Dict[str, Any]) -> None:
|
||||
config['mediaDevices:speakers'] = 0
|
||||
|
||||
|
||||
# -- WebGL <-> screen coherence (#729) ---------------------------------------
|
||||
#
|
||||
# BrowserForge picks navigator/screen; the GPU is drawn separately from
|
||||
# webgl_data.db weighted only by OS. Nothing ties the two together, so the
|
||||
# synthetic path can emit pairs no real machine ships -- a discrete GPU behind
|
||||
# a 1024x600 netbook panel. Consistency checks (Pixelscan, Fingerprint.com)
|
||||
# read that as masking even when every individual value is plausible alone.
|
||||
#
|
||||
# What can honestly be claimed here is narrow, because Firefox never reports
|
||||
# the GPU it actually sees. dom/canvas/SanitizeRenderer.cpp collapses every
|
||||
# renderer string into one of ~11 representative device buckets before a page
|
||||
# sees it (prefs webgl.sanitize-unmasked-renderer and
|
||||
# webgl.enable-renderer-query, both default true; resistFingerprinting
|
||||
# replaces the value with "Mozilla" outright). That file's own header comment
|
||||
# gives the flavour: `"GeForce RTX 3090" => "GeForce GTX 980"`. So every RTX,
|
||||
# every Quadro M/P/V/T and every GeForce 900-7999 arrive as one string, while
|
||||
# "Intel(R) UHD Graphics 620" and "Mesa Intel(R) Iris(R) Xe Graphics" both
|
||||
# arrive as an "Intel(R) HD Graphics" spelling.
|
||||
#
|
||||
# Two consequences. Matching on raw model names -- RTX, Quadro, RX, UHD, Iris,
|
||||
# Mesa Intel -- can never fire, because those are exactly the strings Gecko
|
||||
# collapses away. And a bucket spanning a desktop RTX 4090 and a mobile GTX
|
||||
# 1650 Max-Q carries no useful screen floor: 1366x768 laptops with discrete
|
||||
# NVIDIA GPUs are ordinary hardware, not a tell.
|
||||
#
|
||||
# So the rule below holds only what is true of *every* part behind a bucket,
|
||||
# and renderers are reduced to their bucket first (see _renderer_bucket) so
|
||||
# the ANGLE, nouveau and /PCIe/SSE2 spellings of one GPU land on one rule
|
||||
# instead of three different ones.
|
||||
|
||||
# Software rasterizers. A VM or headless host reports whatever resolution the
|
||||
# window manager hands it, so no screen constrains them -- and the sampler
|
||||
# must never come to *prefer* them, because a software renderer is a far
|
||||
# stronger "this is a bot" signal than any GPU/screen mismatch.
|
||||
_SOFTWARE_RENDERERS: Tuple[str, ...] = (
|
||||
'llvmpipe',
|
||||
'Microsoft Basic Render Driver',
|
||||
'SwiftShader',
|
||||
'Generic Renderer',
|
||||
)
|
||||
|
||||
# Discrete NVIDIA, plus the AMD R5/R7/R9/RX/Vega bucket. Everything else in
|
||||
# webgl_data.db reaches down into netbook territory and gets no floor at all:
|
||||
# the "Intel(R) HD Graphics" bucket swallows the GMA 3150 netbook chipset,
|
||||
# "Radeon HD 3200 Graphics" is Gecko's catch-all for a bare "AMD"/"Radeon"
|
||||
# (the C-50/E-350 netbook APUs included), and Apple silicon drives arbitrary
|
||||
# external monitors from a Mac mini or Mac Studio.
|
||||
_DISCRETE_GPU_BUCKETS: FrozenSet[str] = frozenset(
|
||||
{
|
||||
'GeForce 8800 GTX',
|
||||
'GeForce GTX 480',
|
||||
'GeForce GTX 980',
|
||||
'Radeon R9 200 Series',
|
||||
}
|
||||
)
|
||||
|
||||
# Discrete GPUs did not ship in netbooks, and netbook panels topped out at
|
||||
# 1024x600. That is the whole of the claim.
|
||||
#
|
||||
# It is an area rather than a width x height pair because real panels do not
|
||||
# dominate one another: 1280x800 and 1366x768 are both ordinary laptop
|
||||
# screens, and a per-axis floor taken from either one rejects the other. A
|
||||
# 1366x768 laptop with a discrete GPU is common hardware, not a tell.
|
||||
_NETBOOK_MAX_PIXELS = 1024 * 600
|
||||
|
||||
# The three shapes SanitizeRenderer wraps a device bucket in.
|
||||
_ANGLE_D3D_RE = re.compile(r'^ANGLE \([^,]*, (.*?) Direct3D.*\)$')
|
||||
_ANGLE_VULKAN_RE = re.compile(r'^ANGLE \((.*)\) on Vulkan$')
|
||||
_PCIE_SSE2_RE = re.compile(r'^(.*)/PCIe?/SSE2$')
|
||||
|
||||
|
||||
def _renderer_bucket(renderer: str) -> str:
|
||||
"""Reduce a reported renderer to Gecko's sanitized device bucket.
|
||||
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or
|
||||
similar" (Windows), "NVIDIA GeForce GTX 980/PCIe/SSE2" (Linux proprietary
|
||||
driver) and "GeForce GTX 980, or similar" (nouveau, which loses the vendor
|
||||
prefix) are one GPU class in three spellings. Without this they land on
|
||||
three different rules, or none.
|
||||
"""
|
||||
core = renderer.removesuffix(', or similar')
|
||||
match = _ANGLE_D3D_RE.match(core) or _ANGLE_VULKAN_RE.match(core)
|
||||
if match:
|
||||
core = match.group(1)
|
||||
match = _PCIE_SSE2_RE.match(core)
|
||||
if match:
|
||||
core = match.group(1)
|
||||
# SanitizeRenderer re-adds the "NVIDIA " prefix only when the raw string
|
||||
# carried it, so one bucket arrives both with and without it.
|
||||
return core.removeprefix('NVIDIA ')
|
||||
|
||||
|
||||
# The smallest screen mainstream hardware still ships. BrowserForge's pool
|
||||
# carries netbook-era geometry that essentially no 2026 device reports, and
|
||||
# that is a tell on its own, whatever GPU sits behind it.
|
||||
MODERN_SCREEN_FLOOR: Tuple[int, int] = (1366, 768)
|
||||
|
||||
|
||||
def raise_screen_to_modern_floor(config: Dict[str, Any]) -> None:
|
||||
"""Lift netbook-era screen geometry to something current hardware reports.
|
||||
|
||||
BrowserForge still draws 1024x600 and friends. Those panels left
|
||||
production a decade and a half ago, so the screen is what has to move --
|
||||
no GPU choice makes that profile look current.
|
||||
|
||||
Keeps the screen-to-avail gap intact so fix_screen_no_taskbar's invariant
|
||||
survives; the window box is reconciled by clamp_window_dimensions and
|
||||
clamp_window_position, which run after this. Call BEFORE
|
||||
clamp_screen_to_display so a genuinely small real monitor still wins.
|
||||
"""
|
||||
min_w, min_h = MODERN_SCREEN_FLOOR
|
||||
sw = config.get('screen.width')
|
||||
sh = config.get('screen.height')
|
||||
if not (sw and sh) or (sw >= min_w and sh >= min_h):
|
||||
return
|
||||
|
||||
# Measure the gaps before mutating, or they get folded into themselves.
|
||||
aw = config.get('screen.availWidth')
|
||||
ah = config.get('screen.availHeight')
|
||||
gap_w = sw - aw if aw else None
|
||||
gap_h = sh - ah if ah else None
|
||||
|
||||
new_w, new_h = max(sw, min_w), max(sh, min_h)
|
||||
config['screen.width'] = new_w
|
||||
config['screen.height'] = new_h
|
||||
if gap_w is not None:
|
||||
config['screen.availWidth'] = max(1, new_w - max(0, gap_w))
|
||||
if gap_h is not None:
|
||||
config['screen.availHeight'] = max(1, new_h - max(0, gap_h))
|
||||
|
||||
|
||||
def is_software_renderer(renderer: Optional[str]) -> bool:
|
||||
"""Whether `renderer` is a software rasterizer rather than real hardware."""
|
||||
return bool(renderer) and any(name in renderer for name in _SOFTWARE_RENDERERS)
|
||||
|
||||
|
||||
def gpu_screen_is_plausible(
|
||||
renderer: Optional[str], width: Optional[int], height: Optional[int]
|
||||
) -> bool:
|
||||
"""Whether `renderer` is a GPU that plausibly drives a `width` x `height` screen.
|
||||
|
||||
Unconstrained buckets and software rasterizers pass. The set only names
|
||||
buckets whose floor holds for every part behind them, so anything absent
|
||||
from it is genuinely unconstrained rather than merely unrecognised.
|
||||
"""
|
||||
if not renderer or not width or not height:
|
||||
return True
|
||||
if is_software_renderer(renderer):
|
||||
return True
|
||||
if _renderer_bucket(renderer) not in _DISCRETE_GPU_BUCKETS:
|
||||
return True
|
||||
return width * height > _NETBOOK_MAX_PIXELS
|
||||
|
||||
|
||||
def sample_webgl_for_screen(
|
||||
target_os: str,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
attempts: int = 32,
|
||||
) -> Dict[str, str]:
|
||||
"""Sample a WebGL profile that is coherent with the screen already chosen.
|
||||
|
||||
Rejection sampling, so the GPU keeps webgl_data.db's real OS-weighted
|
||||
distribution -- we only drop draws that contradict the screen. The screen
|
||||
itself is left alone on purpose: it has already been reconciled with the
|
||||
real display and the window box (clamp_screen_to_display,
|
||||
fix_screen_no_taskbar, clamp_window_dimensions, clamp_window_position),
|
||||
and widening it here to flatter the GPU would push a headful window back
|
||||
off the monitor it is drawn on (#499).
|
||||
|
||||
The first draw settles hardware-vs-software at the pool's natural rate and
|
||||
is never resampled once it lands on a rasterizer. Rejecting only hardware
|
||||
draws would renormalise the survivors onto llvmpipe / WARP / SwiftShader:
|
||||
on a small screen that turns a 1.5% software rate into a 40% one, trading
|
||||
a weak incoherence for the strongest VM/headless tell there is.
|
||||
|
||||
Falls back to that first draw when the pool holds nothing coherent, so an
|
||||
unusual screen degrades to today's behaviour rather than raising.
|
||||
"""
|
||||
first = sample_webgl(target_os)
|
||||
renderer = first.get('webGl:renderer')
|
||||
if is_software_renderer(renderer) or gpu_screen_is_plausible(renderer, width, height):
|
||||
return first
|
||||
|
||||
for _ in range(attempts - 1):
|
||||
candidate = sample_webgl(target_os)
|
||||
renderer = candidate.get('webGl:renderer')
|
||||
# Skip rather than accept: the class was settled by the first draw.
|
||||
if is_software_renderer(renderer):
|
||||
continue
|
||||
if gpu_screen_is_plausible(renderer, width, height):
|
||||
return candidate
|
||||
return first
|
||||
|
||||
|
||||
def _select_presets_file(ff_version: Optional[Any] = None) -> Path:
|
||||
"""Pick the bundled-presets file appropriate for a given Firefox version.
|
||||
|
||||
@@ -812,7 +1007,14 @@ def generate_context_fingerprint(
|
||||
else:
|
||||
_target_os = 'mac'
|
||||
try:
|
||||
webgl_fp = sample_webgl(_target_os)
|
||||
# Same coherence treatment launch_options applies (#729): lift
|
||||
# netbook geometry, then keep the GPU consistent with whatever
|
||||
# screen this identity ended up with. This path has no real
|
||||
# display to reconcile against, so the floor is unconditional.
|
||||
raise_screen_to_modern_floor(config)
|
||||
webgl_fp = sample_webgl_for_screen(
|
||||
_target_os, config.get('screen.width'), config.get('screen.height')
|
||||
)
|
||||
webgl_fp.pop('webGl2Enabled', None)
|
||||
config.update(webgl_fp)
|
||||
except Exception:
|
||||
|
||||
@@ -21,7 +21,7 @@ from .exceptions import (
|
||||
InvalidPropertyType,
|
||||
NonFirefoxFingerprint,
|
||||
)
|
||||
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, set_media_devices_defaults
|
||||
from .fingerprints import from_browserforge, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, sample_webgl_for_screen, set_media_devices_defaults
|
||||
from .geolocation import geoip_allowed, get_geolocation
|
||||
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
|
||||
from .locales import handle_locales
|
||||
@@ -741,6 +741,16 @@ def launch_options(
|
||||
if not _user_set_navigator:
|
||||
fix_navigator_arch(config, target_os)
|
||||
if not _user_set_screen_window:
|
||||
# Lift netbook-era geometry to something current hardware reports,
|
||||
# before the display clamp below so a genuinely small real monitor
|
||||
# still wins (#729). Synthetic draws only: a preset is a real device,
|
||||
# internally consistent by construction, and two of the bundled v150
|
||||
# presets genuinely report sub-netbook screens (736x414, 960x540).
|
||||
# Rewriting those to 1366x768 would break the very coherence #729 is
|
||||
# about, and _user_set_screen_window is read before the preset merges
|
||||
# in, so it does not cover this.
|
||||
if not _used_preset:
|
||||
raise_screen_to_modern_floor(config)
|
||||
# Headful on a real monitor only: this bound exists so the window fits
|
||||
# the screen it is drawn on. headless has no window to overflow, and
|
||||
# headless='virtual' reaches here as headless=False (see async_api) with
|
||||
@@ -897,7 +907,13 @@ def launch_options(
|
||||
# Preset already set vendor/renderer — sample matching WebGL params
|
||||
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'])
|
||||
else:
|
||||
webgl_fp = sample_webgl(target_os)
|
||||
# Synthetic path: keep the GPU coherent with the screen BrowserForge
|
||||
# already picked. Sampling the two independently yields pairs no
|
||||
# real machine ships -- a discrete desktop GPU behind a 1024x600
|
||||
# panel -- which consistency checks read as masking (#729).
|
||||
webgl_fp = sample_webgl_for_screen(
|
||||
target_os, config.get('screen.width'), config.get('screen.height')
|
||||
)
|
||||
enable_webgl2 = webgl_fp.pop('webGl2Enabled')
|
||||
|
||||
# Merge the WebGL fingerprint into the config
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Tests for the WebGL <-> screen coherence helpers in camoufox.fingerprints.
|
||||
|
||||
Run with:
|
||||
cd pythonlib && python -m pytest tests/test_webgl_screen_consistency.py -v
|
||||
|
||||
The regression these guard (daijro/camoufox#729): BrowserForge picks the
|
||||
screen, webgl_data.db picks the GPU, and nothing ties them together -- so the
|
||||
synthetic path can emit pairs no real machine ships (a discrete GPU behind a
|
||||
1024x600 netbook panel).
|
||||
|
||||
The constraint has to be read through Gecko's renderer sanitizer
|
||||
(dom/canvas/SanitizeRenderer.cpp), which collapses every renderer string into
|
||||
one of ~11 device buckets before a page sees it. Two things follow, and most
|
||||
of these tests exist to pin them down: raw model names never reach the page,
|
||||
and a bucket spanning a desktop RTX 4090 and a mobile GTX 1650 Max-Q cannot
|
||||
carry a 1080p floor.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from camoufox import fingerprints # noqa: E402
|
||||
from camoufox.fingerprints import ( # noqa: E402
|
||||
MODERN_SCREEN_FLOOR,
|
||||
_renderer_bucket,
|
||||
gpu_screen_is_plausible,
|
||||
is_software_renderer,
|
||||
raise_screen_to_modern_floor,
|
||||
sample_webgl_for_screen,
|
||||
)
|
||||
|
||||
# The three spellings Gecko emits for one discrete-NVIDIA bucket.
|
||||
_NV_ANGLE = "ANGLE (NVIDIA, NVIDIA GeForce GTX 980 Direct3D11 vs_5_0 ps_5_0), or similar"
|
||||
_NV_PCIE = "NVIDIA GeForce GTX 980/PCIe/SSE2"
|
||||
_NV_NOUVEAU = "GeForce GTX 980, or similar"
|
||||
|
||||
_AMD_IGP = "ANGLE (AMD, Radeon HD 3200 Graphics Direct3D11 vs_5_0 ps_5_0), or similar"
|
||||
_INTEL = "ANGLE (Intel, Intel(R) HD Graphics Direct3D11 vs_5_0 ps_5_0), or similar"
|
||||
_APPLE = "Apple M1, or similar"
|
||||
_LLVMPIPE = "llvmpipe, or similar"
|
||||
|
||||
|
||||
# -- bucket reduction --------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("renderer", [_NV_ANGLE, _NV_PCIE, _NV_NOUVEAU])
|
||||
def test_every_spelling_of_one_gpu_reduces_to_one_bucket(renderer):
|
||||
# ANGLE wrapping, the /PCIe/SSE2 suffix and nouveau's missing "NVIDIA "
|
||||
# prefix are the same GPU class. Matching raw substrings put these on
|
||||
# three different rules, so the same hardware got three different floors.
|
||||
assert _renderer_bucket(renderer) == "GeForce GTX 980"
|
||||
|
||||
|
||||
def test_angle_vendor_field_does_not_decide_the_bucket():
|
||||
# Regression: matching the substring "ANGLE (AMD" gave this integrated
|
||||
# chipset a discrete-GPU floor, while its Linux spelling got none.
|
||||
assert _renderer_bucket(_AMD_IGP) == "Radeon HD 3200 Graphics"
|
||||
assert _renderer_bucket("Radeon HD 3200 Graphics, or similar") == (
|
||||
"Radeon HD 3200 Graphics"
|
||||
)
|
||||
|
||||
|
||||
def test_angle_vulkan_form_is_unwrapped():
|
||||
assert _renderer_bucket("ANGLE (Samsung Xclipse 920) on Vulkan") == (
|
||||
"Samsung Xclipse 920"
|
||||
)
|
||||
|
||||
|
||||
# -- what the table constrains ----------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("renderer", [_NV_ANGLE, _NV_PCIE, _NV_NOUVEAU])
|
||||
def test_discrete_gpu_rejected_on_a_netbook_screen(renderer):
|
||||
assert not gpu_screen_is_plausible(renderer, 1024, 600)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"width,height", [(1024, 768), (1280, 720), (1280, 800), (1366, 768), (1920, 1080)]
|
||||
)
|
||||
def test_discrete_gpu_accepted_on_any_real_laptop_panel(width, height):
|
||||
# A 1366x768 laptop with a discrete NVIDIA GPU is ordinary hardware, and
|
||||
# "GeForce GTX 980, or similar" is the bucket a mobile GTX 1650 Max-Q
|
||||
# reports. Anything stricter rejects real machines.
|
||||
assert gpu_screen_is_plausible(_NV_ANGLE, width, height)
|
||||
|
||||
|
||||
def test_the_floor_is_an_area_not_a_per_axis_bound():
|
||||
# 1280x800 and 1366x768 are both ordinary panels and neither dominates the
|
||||
# other, so a per-axis floor taken from one throws out the other.
|
||||
assert gpu_screen_is_plausible(_NV_ANGLE, 1280, 800)
|
||||
assert gpu_screen_is_plausible(_NV_ANGLE, 1366, 768)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width,height", [(1024, 600), (800, 480), (1024, 576)])
|
||||
def test_discrete_gpu_rejected_on_netbook_panels(width, height):
|
||||
assert not gpu_screen_is_plausible(_NV_ANGLE, width, height)
|
||||
|
||||
|
||||
def test_integrated_parts_are_not_floored():
|
||||
# Gecko's "Intel(R) HD Graphics" bucket swallows the GMA 3150 netbook
|
||||
# chipset, and "Radeon HD 3200 Graphics" is its catch-all for a bare
|
||||
# "AMD"/"Radeon" -- including netbook APUs. Neither carries a floor.
|
||||
assert gpu_screen_is_plausible(_INTEL, 1024, 600)
|
||||
assert gpu_screen_is_plausible(_AMD_IGP, 1024, 600)
|
||||
|
||||
|
||||
def test_apple_silicon_is_not_pinned_to_retina():
|
||||
# Apple silicon also ships in the Mac mini / Mac Studio, which drive
|
||||
# whatever external monitor is attached.
|
||||
assert gpu_screen_is_plausible(_APPLE, 1920, 1080)
|
||||
assert gpu_screen_is_plausible(_APPLE, 1280, 800)
|
||||
|
||||
|
||||
def test_raw_model_names_are_out_of_scope():
|
||||
# Firefox never reports these: SanitizeRenderer turns an RTX 3070 into
|
||||
# "GeForce GTX 980" and a UHD 620 into "Intel(R) HD Graphics 400" before
|
||||
# the page sees anything. Keying on them was matching nothing.
|
||||
assert gpu_screen_is_plausible(
|
||||
"ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Direct3D11 vs_5_0 ps_5_0)", 1024, 600
|
||||
)
|
||||
|
||||
|
||||
def test_missing_values_are_not_constrained():
|
||||
assert gpu_screen_is_plausible(None, 1920, 1080)
|
||||
assert gpu_screen_is_plausible(_NV_ANGLE, None, None)
|
||||
|
||||
|
||||
# -- software rasterizers ----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"renderer",
|
||||
[
|
||||
_LLVMPIPE,
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)",
|
||||
"ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)",
|
||||
"Generic Renderer",
|
||||
],
|
||||
)
|
||||
def test_software_renderers_are_unconstrained(renderer):
|
||||
# A VM reports whatever the window manager hands it.
|
||||
assert is_software_renderer(renderer)
|
||||
assert gpu_screen_is_plausible(renderer, 1024, 600)
|
||||
|
||||
|
||||
def test_hardware_is_not_mistaken_for_software(monkeypatch):
|
||||
for renderer in (_NV_ANGLE, _INTEL, _APPLE, _AMD_IGP):
|
||||
assert not is_software_renderer(renderer)
|
||||
|
||||
|
||||
def test_software_first_draw_is_never_resampled(monkeypatch):
|
||||
"""The strongest reason this sampler must not be a plain reject loop.
|
||||
|
||||
Rejecting hardware draws while accepting every software one renormalises
|
||||
the pool onto llvmpipe / WARP / SwiftShader. On a sub-floor screen that
|
||||
turned a 1.5% software rate into ~40%, trading a weak incoherence for the
|
||||
strongest VM/headless tell there is. So the first draw settles the class.
|
||||
"""
|
||||
draws = iter([{"webGl:renderer": _LLVMPIPE}, {"webGl:renderer": _INTEL}])
|
||||
monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(draws))
|
||||
|
||||
# 1024x600 would reject a discrete GPU, but llvmpipe is plausible there and
|
||||
# must be returned as drawn rather than swapped for the Intel part.
|
||||
assert sample_webgl_for_screen("lin", 1024, 600)["webGl:renderer"] == _LLVMPIPE
|
||||
|
||||
|
||||
def test_software_draws_are_skipped_when_resampling(monkeypatch):
|
||||
# A hardware first draw settles the class as hardware, so a software
|
||||
# candidate mid-loop is skipped instead of accepted -- otherwise the
|
||||
# rejection loop still leaks probability mass onto the rasterizers.
|
||||
draws = iter(
|
||||
[
|
||||
{"webGl:renderer": _NV_ANGLE}, # implausible at 1024x600
|
||||
{"webGl:renderer": _LLVMPIPE}, # plausible, but wrong class
|
||||
{"webGl:renderer": _INTEL}, # the coherent hardware answer
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(draws))
|
||||
|
||||
assert sample_webgl_for_screen("lin", 1024, 600)["webGl:renderer"] == _INTEL
|
||||
|
||||
|
||||
def test_falls_back_to_the_first_draw_when_nothing_is_coherent(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
fingerprints, "sample_webgl", lambda *a, **kw: {"webGl:renderer": _NV_ANGLE}
|
||||
)
|
||||
fp = sample_webgl_for_screen("win", 800, 600, attempts=4)
|
||||
assert fp["webGl:renderer"] == _NV_ANGLE
|
||||
|
||||
|
||||
def test_a_plausible_first_draw_costs_one_query(monkeypatch):
|
||||
# sample_webgl opens a fresh sqlite connection per call, and the common
|
||||
# case (any screen at or above the floor) must not pay for 32 of them.
|
||||
calls = []
|
||||
|
||||
def _counted(*args, **kwargs):
|
||||
calls.append(args)
|
||||
return {"webGl:renderer": _NV_ANGLE}
|
||||
|
||||
monkeypatch.setattr(fingerprints, "sample_webgl", _counted)
|
||||
sample_webgl_for_screen("win", 1920, 1080)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_os", ["win", "mac", "lin"])
|
||||
def test_sampled_gpu_is_coherent_with_the_screen(target_os):
|
||||
# Against the real webgl_data.db pool.
|
||||
for _ in range(25):
|
||||
fp = sample_webgl_for_screen(target_os, 1280, 800)
|
||||
assert gpu_screen_is_plausible(fp.get("webGl:renderer"), 1280, 800)
|
||||
|
||||
|
||||
# -- the screen floor --------------------------------------------------------
|
||||
|
||||
|
||||
def test_screen_floor_lifts_netbook_geometry():
|
||||
config = {
|
||||
"screen.width": 1024,
|
||||
"screen.height": 600,
|
||||
"screen.availWidth": 1024,
|
||||
"screen.availHeight": 560,
|
||||
}
|
||||
raise_screen_to_modern_floor(config)
|
||||
assert (config["screen.width"], config["screen.height"]) == MODERN_SCREEN_FLOOR
|
||||
# The taskbar gap has to survive, or fix_screen_no_taskbar's invariant --
|
||||
# and CreepJS's noTaskbar check -- breaks.
|
||||
assert config["screen.height"] - config["screen.availHeight"] == 40
|
||||
assert config["screen.width"] - config["screen.availWidth"] == 0
|
||||
assert config["screen.availHeight"] < config["screen.height"]
|
||||
|
||||
|
||||
def test_screen_floor_leaves_an_adequate_screen_alone():
|
||||
config = {
|
||||
"screen.width": 1920,
|
||||
"screen.height": 1080,
|
||||
"screen.availWidth": 1920,
|
||||
"screen.availHeight": 1040,
|
||||
}
|
||||
before = dict(config)
|
||||
raise_screen_to_modern_floor(config)
|
||||
assert config == before
|
||||
|
||||
|
||||
def test_screen_floor_is_a_no_op_without_screen_values():
|
||||
config = {}
|
||||
raise_screen_to_modern_floor(config)
|
||||
assert config == {}
|
||||
|
||||
|
||||
# -- the two entry points must agree ----------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_os", ["windows", "macos", "linux"])
|
||||
def test_context_fingerprints_get_the_same_treatment(target_os):
|
||||
"""generate_context_fingerprint() is the per-context API #729 names, and
|
||||
build-tester drives the browser through it. It sampled the GPU with a bare
|
||||
sample_webgl() and never applied the floor, so the coherence fix reached
|
||||
launch_options() only."""
|
||||
from camoufox.fingerprints import generate_context_fingerprint
|
||||
|
||||
for _ in range(15):
|
||||
config = generate_context_fingerprint(os=target_os)["config"]
|
||||
renderer = config.get("webGl:renderer")
|
||||
width, height = config.get("screen.width"), config.get("screen.height")
|
||||
assert renderer and width and height
|
||||
assert gpu_screen_is_plausible(renderer, width, height)
|
||||
assert width * height > 1024 * 600
|
||||
|
||||
|
||||
def test_preset_screens_are_never_lifted():
|
||||
"""Presets are real devices, coherent by construction -- #729 says so
|
||||
explicitly. Two bundled v150 presets report genuinely sub-netbook screens,
|
||||
and the floor used to rewrite them to 1366x768 because
|
||||
_user_set_screen_window is computed before the preset merges in."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from camoufox.utils import launch_options
|
||||
|
||||
presets = json.loads(
|
||||
(Path(__file__).parent.parent / "camoufox" / "fingerprint-presets-v150.json").read_text()
|
||||
)
|
||||
|
||||
def small_presets(node):
|
||||
if isinstance(node, dict):
|
||||
screen = node.get("screen")
|
||||
if (
|
||||
isinstance(screen, dict)
|
||||
and screen.get("width")
|
||||
and screen.get("height")
|
||||
and screen["width"] * screen["height"] <= 1024 * 600
|
||||
):
|
||||
yield node
|
||||
for value in node.values():
|
||||
yield from small_presets(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
yield from small_presets(value)
|
||||
|
||||
found = list(small_presets(presets))
|
||||
assert found, "expected the v150 presets to still carry sub-netbook screens"
|
||||
|
||||
for preset in found:
|
||||
env = launch_options(
|
||||
headless=True, fingerprint_preset=preset, i_know_what_im_doing=True
|
||||
)["env"]
|
||||
raw = "".join(
|
||||
env[k]
|
||||
for k in sorted(
|
||||
(k for k in env if k.startswith("CAMOU_CONFIG_")),
|
||||
key=lambda k: int(k.rsplit("_", 1)[1]),
|
||||
)
|
||||
)
|
||||
config = json.loads(raw)
|
||||
assert (config["screen.width"], config["screen.height"]) == (
|
||||
preset["screen"]["width"],
|
||||
preset["screen"]["height"],
|
||||
)
|
||||
Reference in New Issue
Block a user