mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-04 00:00:26 +00:00
feat(python): draw every identity's WebGL from fpgen
WebGL vendor, renderer, context attributes, extensions, parameters and shader precisions, for WebGL1 and WebGL2, now come from fpgen's recorded Firefox devices instead of webgl_data.db, which is deleted with the camoufox/webgl/ package. camoufox/webgl.py: - webgl_for_gpu() traces `webgl` given Firefox, the OS and the GPU, then `webgl2` given the chosen `webgl` too, and draws each with one seeded random.Random. The GPU and the webgl value are pinned by their fpgen lookup index: a dict condition is flattened into leaves that overwrite each other, so only the renderer applied and Linux "Mesa" and "AMD" Radeon HD 3200 devices came back mixed. - sample_webgl_for_screen() draws the GPU of a generated identity from fpgen's per-OS weights, filtering out software rasterisers, GPUs the OS cannot report, discrete GPUs behind a netbook screen and the resistFingerprinting "Mozilla" mask before the weighted choice, so there is no rejection loop. An empty pool raises. - The draft/host-dependent extension filter moves over unchanged. A preset's GPU and a caller's webgl_config pair are looked up as given; a pair fpgen has never seen from Firefox on that OS raises instead of falling back to another GPU. generate_context_fingerprint no longer falls back to the host GPU when the draw fails. For 10 of the 15 (GPU, OS) pairs the two sources share, one of fpgen's records converts to exactly the database row on every value the browser reads. The other five rows (Linux R9 200 and Radeon HD 3200, macOS Intel HD, and two software rasterisers) are devices fpgen does not carry; those GPUs now present fpgen's recorded devices instead. The Linux GTX 980 row is kept as a test fixture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
40edebb925
commit
0d859b3a3d
@@ -3,10 +3,8 @@ import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
from dataclasses import asdict, dataclass, is_dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from random import Random, choice, randint, randrange
|
||||
from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
|
||||
@@ -14,7 +12,6 @@ from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
|
||||
from camoufox._warnings import FallbackWarning
|
||||
from camoufox.ip import valid_ipv4, validate_ip
|
||||
from camoufox.pkgman import load_yaml
|
||||
from camoufox.webgl import sample_webgl
|
||||
|
||||
# Load the fpgen mapping file
|
||||
FPGEN_DATA = load_yaml('fpgen.yml')
|
||||
@@ -38,21 +35,6 @@ def _generator():
|
||||
return _FP_GENERATOR
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def firefox_gpus(os_name: str) -> FrozenSet[Tuple[str, str]]:
|
||||
"""Every (vendor, renderer) that fpgen has seen Firefox report on this OS.
|
||||
|
||||
A GPU outside this set has no recorded WebGL parameters behind it, so an
|
||||
identity naming it could only borrow another device's.
|
||||
"""
|
||||
import fpgen
|
||||
|
||||
return frozenset(
|
||||
(result.value['vendor'], result.value['renderer'])
|
||||
for result in fpgen.trace(target='gpu', browser='Firefox', os=_FPGEN_OS[os_name.lower()])
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Screen:
|
||||
"""A bound on the screen a generated fingerprint may claim.
|
||||
@@ -1292,8 +1274,8 @@ def draw_media_devices(os_key: str, seed: Optional[int]) -> Dict[str, Any]:
|
||||
|
||||
# -- 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
|
||||
# fpgen picks navigator/screen; the GPU is drawn separately, weighted only by
|
||||
# OS (camoufox.webgl). 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.
|
||||
@@ -1331,8 +1313,8 @@ _SOFTWARE_RENDERERS: Tuple[str, ...] = (
|
||||
'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:
|
||||
# Discrete NVIDIA, plus the AMD R5/R7/R9/RX/Vega bucket. Every other GPU
|
||||
# Firefox reports 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
|
||||
@@ -1444,57 +1426,6 @@ def gpu_screen_is_plausible(
|
||||
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,
|
||||
seed: Optional[int] = None,
|
||||
) -> 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).
|
||||
|
||||
Software rasterisers are the one exception to keeping the pool's rate: a
|
||||
draw that lands on llvmpipe / WARP / SwiftShader is resampled, so they
|
||||
never present as the GPU (see below). That does cost fidelity -- the corpus
|
||||
records them at ~1.5%, because real users do run without working drivers --
|
||||
but "no consumer machine reports llvmpipe" is a live, standard check on a
|
||||
string every fingerprint script already reads, so the trade is worth it.
|
||||
Reviewed against the JS-detectability bar on 2026-09-17 and kept.
|
||||
|
||||
Falls back to that first draw when the pool holds nothing coherent, so an
|
||||
unusual screen degrades to today's behaviour rather than raising.
|
||||
"""
|
||||
# A software rasteriser (llvmpipe / SwiftShader / WARP) as the presented
|
||||
# GPU is what every consumer-hardware check flags first ("no consumer
|
||||
# machine reports llvmpipe" -- sundial, measured 2026-09-14), so the draw
|
||||
# never settles on one: keep drawing until a hardware renderer that fits
|
||||
# the screen comes up, and only fall back to the first draw if the pool
|
||||
# holds nothing better.
|
||||
first = sample_webgl(target_os, seed=seed)
|
||||
renderer = first.get('webGl:renderer')
|
||||
if not is_software_renderer(renderer) and gpu_screen_is_plausible(renderer, width, height):
|
||||
return first
|
||||
|
||||
fallback = None if is_software_renderer(renderer) else first
|
||||
for attempt in range(attempts - 1):
|
||||
candidate = sample_webgl(target_os, seed=None if seed is None else seed + 1 + attempt)
|
||||
renderer = candidate.get('webGl:renderer')
|
||||
if is_software_renderer(renderer):
|
||||
continue
|
||||
if gpu_screen_is_plausible(renderer, width, height):
|
||||
return candidate
|
||||
fallback = fallback or candidate
|
||||
return fallback or first
|
||||
|
||||
|
||||
def _select_presets_file(ff_version: Optional[Any] = None) -> Path:
|
||||
"""Pick the bundled-presets file appropriate for a given Firefox version.
|
||||
|
||||
@@ -1890,8 +1821,11 @@ def generate_context_fingerprint(
|
||||
elif 'Linux' in plat or 'linux' in plat:
|
||||
config['navigator.oscpu'] = 'Linux x86_64'
|
||||
|
||||
# Sample WebGL vendor/renderer from database (fpgen.yml does not map these yet)
|
||||
# Draw the GPU and its WebGL data (fpgen.yml does not map these)
|
||||
if not config.get('webGl:vendor') or not config.get('webGl:renderer'):
|
||||
# Not at the top: camoufox.webgl imports this module.
|
||||
from .webgl import sample_webgl_for_screen
|
||||
|
||||
_os_map = {'macos': 'mac', 'linux': 'lin', 'windows': 'win'}
|
||||
_target_os = _os_map.get(os or '', None)
|
||||
if not _target_os:
|
||||
@@ -1907,18 +1841,11 @@ def generate_context_fingerprint(
|
||||
# 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)
|
||||
try:
|
||||
webgl_fp = sample_webgl_for_screen(
|
||||
_target_os, config.get('screen.width'), config.get('screen.height')
|
||||
)
|
||||
except (ValueError, sqlite3.Error) as e:
|
||||
FallbackWarning.warn(
|
||||
'Drawing the WebGL GPU', "the browser's launch-time GPU", e,
|
||||
config.get('navigator.userAgent'),
|
||||
)
|
||||
else:
|
||||
webgl_fp.pop('webGl2Enabled', None)
|
||||
config.update(webgl_fp)
|
||||
webgl_fp = sample_webgl_for_screen(
|
||||
_target_os, config.get('screen.width'), config.get('screen.height')
|
||||
)
|
||||
webgl_fp.pop('webGl2Enabled')
|
||||
config.update(webgl_fp)
|
||||
|
||||
# Build source dicts from the fingerprint config for init_values
|
||||
nav = {
|
||||
|
||||
+12
-31
@@ -22,7 +22,7 @@ from .exceptions import (
|
||||
InvalidPropertyType,
|
||||
NonFirefoxFingerprint,
|
||||
)
|
||||
from .fingerprints import Screen, from_fpgen, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_hardware_concurrency, identity_salt, identity_seed, 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, WINDOWS_11_MARKER_FONTS
|
||||
from .fingerprints import Screen, from_fpgen, from_preset, generate_fingerprint, get_random_preset, _generate_random_font_subset, _generate_random_voice_subset, fix_navigator_arch, fix_hardware_concurrency, identity_salt, identity_seed, fix_screen_no_taskbar, clamp_screen_to_display, clamp_window_dimensions, clamp_window_position, raise_screen_to_modern_floor, set_media_devices_defaults, WINDOWS_11_MARKER_FONTS
|
||||
from . import coherence
|
||||
from .geolocation import geoip_allowed, get_geolocation
|
||||
from .ip import Proxy, public_ip, valid_ipv4, valid_ipv6
|
||||
@@ -41,7 +41,7 @@ from .pkgman import (
|
||||
)
|
||||
from .virtdisplay import VirtualDisplay
|
||||
from ._warnings import FallbackWarning, LeakWarning
|
||||
from .webgl import sample_webgl
|
||||
from .webgl import sample_webgl_for_screen, webgl_for_gpu
|
||||
|
||||
ListOrString: TypeAlias = Union[Tuple[str, ...], List[str], str]
|
||||
|
||||
@@ -1423,37 +1423,18 @@ def launch_options(
|
||||
firefox_user_prefs['webgl.disabled'] = True
|
||||
LeakWarning.warn('block_webgl', i_know_what_im_doing)
|
||||
else:
|
||||
# If the user has provided a specific WebGL vendor/renderer pair, use it
|
||||
# A pair the caller named, or the preset's own GPU, keeps its name and
|
||||
# gets that device's recorded parameters. webgl_for_gpu raises for a GPU
|
||||
# fpgen has never seen: the caller asked for something that does not exist.
|
||||
if webgl_config:
|
||||
webgl_fp = sample_webgl(target_os, *webgl_config, seed=identity_seed(config, _identity_salt))
|
||||
webgl_fp = webgl_for_gpu(target_os, *webgl_config, seed=identity_seed(config, _identity_salt))
|
||||
elif config.get('webGl:vendor') and config.get('webGl:renderer'):
|
||||
# Preset already set vendor/renderer — sample matching WebGL params
|
||||
try:
|
||||
webgl_fp = sample_webgl(target_os, config['webGl:vendor'], config['webGl:renderer'], seed=identity_seed(config, _identity_salt))
|
||||
except ValueError as e:
|
||||
# The pair is not in webgl_data.db, which holds 31 GPUs; 36 of the
|
||||
# 397 bundled presets name one it does not have. The parameters,
|
||||
# extension list and shader precisions must all come from one
|
||||
# recorded device, and there is none for this renderer, so the
|
||||
# identity takes a GPU drawn to fit the screen instead.
|
||||
webgl_fp = sample_webgl_for_screen(
|
||||
target_os, config.get('screen.width'), config.get('screen.height'),
|
||||
seed=identity_seed(config, _identity_salt),
|
||||
)
|
||||
# merge_into does not overwrite keys that are already set, and the
|
||||
# preset set these two. Drop them, or the page would read the
|
||||
# preset's renderer string with another device's parameters,
|
||||
# extensions and shader precisions behind it -- a mismatch louder
|
||||
# than the unknown GPU we are replacing.
|
||||
preset_gpu = f"{config.pop('webGl:vendor', None)} / {config.pop('webGl:renderer', None)}"
|
||||
FallbackWarning.warn(
|
||||
f"Finding the preset's GPU ({preset_gpu}) in webgl_data.db",
|
||||
f"a GPU drawn to fit the screen ({webgl_fp['webGl:renderer']})",
|
||||
e,
|
||||
config.get('navigator.userAgent'),
|
||||
)
|
||||
webgl_fp = webgl_for_gpu(
|
||||
target_os, config['webGl:vendor'], config['webGl:renderer'],
|
||||
seed=identity_seed(config, _identity_salt),
|
||||
)
|
||||
else:
|
||||
# Synthetic path: keep the GPU coherent with the screen BrowserForge
|
||||
# Synthetic path: keep the GPU coherent with the screen fpgen
|
||||
# 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).
|
||||
@@ -1477,7 +1458,7 @@ def launch_options(
|
||||
# Every identity passes the whole-identity checks, whatever built it: a
|
||||
# generated fingerprint, a bundled preset, or a config the caller wrote.
|
||||
# The pools are sampled independently -- navigator and screen from the
|
||||
# generator, GPU from webgl_data.db, fonts and voices from their own
|
||||
# generator, GPU from fpgen's WebGL records, fonts and voices from their own
|
||||
# catalogues -- so a machine that never existed can be assembled from parts
|
||||
# that are each fine on their own. See coherence.py.
|
||||
_incoherent = coherence.apply(config, target_os)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""WebGL identities, drawn from the Firefox devices fpgen has recorded.
|
||||
|
||||
Everything a page can read from WebGL -- vendor, renderer, context attributes,
|
||||
extensions, parameters and shader precisions, for WebGL1 and WebGL2 -- comes
|
||||
from one recorded device. fpgen's `webgl` node is conditioned on the GPU, and
|
||||
`webgl2` on the GPU and the `webgl` chosen for it, so a WebGL2 limit never
|
||||
contradicts its WebGL1 counterpart.
|
||||
|
||||
Every draw takes one `random.Random`, in a fixed order (GPU, then webgl, then
|
||||
webgl2), so a seeded identity always presents the same device.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from random import Random
|
||||
from typing import Any, Dict, FrozenSet, Optional, Tuple
|
||||
|
||||
import orjson
|
||||
|
||||
from .coherence import gpu_fits_os
|
||||
from .fingerprints import _FPGEN_OS, gpu_screen_is_plausible, is_software_renderer
|
||||
|
||||
# Extensions a release Firefox never exposes (draft extensions behind
|
||||
# webgl.enable-draft-extensions, or mobile-only): fpgen's corpus carries some
|
||||
# of them, and a spoofed list that names one is a tell on its own. Measured on
|
||||
# stock Firefox 152.0.4 on real Windows 11 (ANGLE D3D11) 2026-09-16: none of
|
||||
# these four are exposed. WEBGL_provoking_vertex is NOT in this set: stock
|
||||
# Firefox on Apple GPUs and on Windows does expose it.
|
||||
_NEVER_EXPOSED_EXTENSIONS = frozenset(
|
||||
{
|
||||
'WEBGL_multi_draw',
|
||||
'WEBGL_clip_cull_distance',
|
||||
'EXT_texture_norm16',
|
||||
'WEBGL_compressed_texture_etc1',
|
||||
}
|
||||
)
|
||||
|
||||
# OVR_multiview2 is a RELEASE extension whose availability depends on the
|
||||
# graphics backend. On Windows, Firefox renders WebGL through ANGLE's D3D11
|
||||
# backend, which implements multiview on every D3D11 GPU: stock 152.0.4 on a
|
||||
# Windows 11 host exposes it on WebGL2 (MAX_VIEWS_OVR=4, headless and headed),
|
||||
# and fpgen records it on ~99% of Windows WebGL2 devices -- filtering it there
|
||||
# was a leak. On Linux it depends on the host GL driver (stock on an NVIDIA box
|
||||
# does not expose it), and ClientWebGLContext::IsSupported answers from the
|
||||
# spoofed list without asking the host, so a Linux identity could advertise an
|
||||
# extension the GPU cannot back; it stays filtered off Windows.
|
||||
_HOST_DEPENDENT_EXTENSIONS = frozenset({'OVR_multiview2'})
|
||||
|
||||
# What Firefox reports under privacy.resistFingerprinting. A Camoufox identity
|
||||
# presents none of RFP's other changes (UTC, rounded windows), so "Mozilla"
|
||||
# beside them names a browser that does not exist.
|
||||
_RFP_RENDERER = 'Mozilla'
|
||||
|
||||
|
||||
def _filtered_extensions(target_os: str) -> FrozenSet[str]:
|
||||
if target_os == 'win':
|
||||
return _NEVER_EXPOSED_EXTENSIONS
|
||||
return _NEVER_EXPOSED_EXTENSIONS | _HOST_DEPENDENT_EXTENSIONS
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _lookup_index(node: str) -> Dict[str, str]:
|
||||
"""fpgen's lookup index for every value of `node`, keyed by its JSON."""
|
||||
from fpgen.utils import _lookup_possibilities
|
||||
|
||||
return _lookup_possibilities(node, casefold=False)
|
||||
|
||||
|
||||
def _pin(node: str, value: Any) -> Tuple[str, str]:
|
||||
"""Evidence fixing `node` to exactly `value`.
|
||||
|
||||
A dict passed to fpgen as a condition is flattened into one condition per
|
||||
leaf, and each leaf replaces the node's evidence, so only the last one
|
||||
applies ("Mesa" and "AMD" Radeon HD 3200 would come back mixed). Pinning
|
||||
the value's own lookup index is exact.
|
||||
"""
|
||||
return node, _lookup_index(node)[orjson.dumps(value).decode()]
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _trace(target: str, target_os: str, pinned: Tuple[Tuple[str, str], ...] = ()) -> Tuple[Any, ...]:
|
||||
"""fpgen's distribution of `target` for Firefox on `target_os`, in its order."""
|
||||
import fpgen
|
||||
|
||||
return tuple(
|
||||
fpgen.trace(
|
||||
target=target,
|
||||
browser='Firefox',
|
||||
os=_FPGEN_OS[target_os],
|
||||
__evidence__={node: {index} for node, index in pinned},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _choose(rng: Random, results: Tuple[Any, ...]) -> Any:
|
||||
return rng.choices(results, weights=[result.probability for result in results])[0].value
|
||||
|
||||
|
||||
def firefox_gpus(target_os: str) -> FrozenSet[Tuple[str, str]]:
|
||||
"""Every (vendor, renderer) that fpgen has seen Firefox report on this OS.
|
||||
|
||||
A GPU outside this set has no recorded WebGL parameters behind it, so an
|
||||
identity naming it could only borrow another device's.
|
||||
"""
|
||||
return frozenset(
|
||||
(result.value['vendor'], result.value['renderer'])
|
||||
for result in _trace('gpu', target_os.lower())
|
||||
)
|
||||
|
||||
|
||||
def _context_config(prefix: str, webgl: Dict[str, Any], target_os: str) -> Dict[str, Any]:
|
||||
blocked = _filtered_extensions(target_os)
|
||||
return {
|
||||
f'{prefix}:contextAttributes': webgl['contextAttributes'],
|
||||
f'{prefix}:supportedExtensions': [
|
||||
extension for extension in webgl['supportedExtensions'] if extension not in blocked
|
||||
],
|
||||
f'{prefix}:parameters': {pname: param['value'] for pname, param in webgl['params'].items()},
|
||||
f'{prefix}:shaderPrecisionFormats': {
|
||||
f"{entry['shaderType']},{entry['precisionType']}": entry['shaderPrecisionFormat']
|
||||
for entry in webgl['shaderPrecisionFormats']
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def to_config(webgl: Dict[str, Any], webgl2: Any, target_os: str) -> Dict[str, Any]:
|
||||
"""fpgen's `webgl` and `webgl2` values as Camoufox config keys.
|
||||
|
||||
`webgl2` is `[]` for a device without WebGL2.
|
||||
"""
|
||||
config = {
|
||||
'webGl:vendor': webgl['vendor'],
|
||||
'webGl:renderer': webgl['renderer'],
|
||||
**_context_config('webGl', webgl, target_os),
|
||||
'webGl2Enabled': bool(webgl2),
|
||||
}
|
||||
if webgl2:
|
||||
config.update(_context_config('webGl2', webgl2, target_os))
|
||||
# The values are fpgen's cached objects; the caller gets its own copy.
|
||||
return orjson.loads(orjson.dumps(config))
|
||||
|
||||
|
||||
def _webgl_config(target_os: str, gpu: Dict[str, str], rng: Random) -> Dict[str, Any]:
|
||||
gpu_pin = _pin('gpu', gpu)
|
||||
webgl = _choose(rng, _trace('webgl', target_os, (gpu_pin,)))
|
||||
webgl2 = _choose(rng, _trace('webgl2', target_os, (gpu_pin, _pin('webgl', webgl))))
|
||||
return to_config(webgl, webgl2, target_os)
|
||||
|
||||
|
||||
def webgl_for_gpu(target_os: str, vendor: str, renderer: str, seed: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""The WebGL config of a device with this GPU, as Firefox on `target_os` reports it.
|
||||
|
||||
Raises ValueError for a GPU fpgen has never seen Firefox report on that OS:
|
||||
it has no recorded parameters, and another device's would contradict it.
|
||||
"""
|
||||
if (vendor, renderer) not in firefox_gpus(target_os):
|
||||
raise ValueError(
|
||||
f'No recorded WebGL data for vendor {vendor!r} and renderer {renderer!r} '
|
||||
f'from Firefox on {_FPGEN_OS[target_os]}. Possible pairs: '
|
||||
f'{sorted(firefox_gpus(target_os))}'
|
||||
)
|
||||
return _webgl_config(target_os, {'vendor': vendor, 'renderer': renderer}, Random(seed))
|
||||
|
||||
|
||||
def sample_webgl_for_screen(
|
||||
target_os: str,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
seed: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Draw a GPU for a synthetic identity, weighted as fpgen records Firefox
|
||||
on `target_os`, and its WebGL config.
|
||||
|
||||
Only GPUs the rest of the identity can stand beside are drawn: never a
|
||||
software rasteriser, a GPU the OS cannot report, the resistFingerprinting
|
||||
mask, or a discrete GPU behind a netbook panel (see gpu_screen_is_plausible).
|
||||
The screen is left alone: it has already been reconciled with the real
|
||||
display and the window (#499).
|
||||
|
||||
Software rasterisers cost fidelity -- real users do run without working
|
||||
drivers -- but "no consumer machine reports llvmpipe" is a live, standard
|
||||
check on a string every fingerprint script reads (sundial, 2026-09-14).
|
||||
"""
|
||||
candidates = tuple(
|
||||
result
|
||||
for result in _trace('gpu', target_os)
|
||||
if not is_software_renderer(result.value['renderer'])
|
||||
and result.value['renderer'] != _RFP_RENDERER
|
||||
and gpu_fits_os(result.value['renderer'], target_os)
|
||||
and gpu_screen_is_plausible(result.value['renderer'], width, height)
|
||||
)
|
||||
if not candidates:
|
||||
raise ValueError(f'No recorded {target_os} GPU fits a {width}x{height} screen')
|
||||
rng = Random(seed)
|
||||
return _webgl_config(target_os, _choose(rng, candidates), rng)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .sample import sample_webgl
|
||||
|
||||
__all__ = ['sample_webgl']
|
||||
@@ -1,167 +0,0 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import orjson
|
||||
|
||||
from camoufox.pkgman import OS_ARCH_MATRIX
|
||||
|
||||
# Get database path relative to this file
|
||||
DB_PATH = Path(__file__).parent / 'webgl_data.db'
|
||||
|
||||
# Extensions a release Firefox never exposes (draft extensions behind
|
||||
# webgl.enable-draft-extensions, or mobile-only): some database rows carry
|
||||
# them, and a spoofed list that names one is a tell on its own. Measured on
|
||||
# stock Firefox 152.0.4 on real Windows 11 (ANGLE D3D11) 2026-09-16: none of
|
||||
# these four are exposed. WEBGL_provoking_vertex is NOT in this set: stock
|
||||
# Firefox on Apple GPUs and on Windows does expose it.
|
||||
_NEVER_EXPOSED_EXTENSIONS = frozenset(
|
||||
{
|
||||
'WEBGL_multi_draw',
|
||||
'WEBGL_clip_cull_distance',
|
||||
'EXT_texture_norm16',
|
||||
'WEBGL_compressed_texture_etc1',
|
||||
}
|
||||
)
|
||||
|
||||
# OVR_multiview2 is a RELEASE extension whose availability depends on the
|
||||
# graphics backend. On Windows, Firefox renders WebGL through ANGLE's D3D11
|
||||
# backend, which implements multiview on every D3D11 GPU: stock 152.0.4 on
|
||||
# a Windows 11 host exposes it on WebGL2 (MAX_VIEWS_OVR=4, headless and headed), and the
|
||||
# recorded corpus has it on 13 of the 15 Windows rows with WebGL2 (never on
|
||||
# WebGL1) -- filtering it there was a leak. On Linux it depends on
|
||||
# the host GL driver (stock on an NVIDIA box does not expose it), and
|
||||
# ClientWebGLContext::IsSupported answers from the spoofed list without asking
|
||||
# the host, so a Linux identity could advertise an extension the GPU cannot
|
||||
# back; it stays filtered off Windows.
|
||||
_HOST_DEPENDENT_EXTENSIONS = frozenset({'OVR_multiview2'})
|
||||
|
||||
|
||||
def _filtered_extensions(os: str) -> frozenset:
|
||||
if os == 'win':
|
||||
return _NEVER_EXPOSED_EXTENSIONS
|
||||
return _NEVER_EXPOSED_EXTENSIONS | _HOST_DEPENDENT_EXTENSIONS
|
||||
|
||||
|
||||
def _load_webgl_data(data_str: str, os: str) -> Dict[str, str]:
|
||||
data = orjson.loads(data_str)
|
||||
blocked = _filtered_extensions(os)
|
||||
for key in ('webGl:supportedExtensions', 'webGl2:supportedExtensions'):
|
||||
exts = data.get(key)
|
||||
if isinstance(exts, list):
|
||||
data[key] = [e for e in exts if e not in blocked]
|
||||
return data
|
||||
|
||||
|
||||
def sample_webgl(
|
||||
os: str, vendor: Optional[str] = None, renderer: Optional[str] = None, seed: Optional[int] = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Sample a random WebGL vendor/renderer combination and its data based on OS probabilities.
|
||||
Optionally use a specific vendor/renderer pair.
|
||||
|
||||
Args:
|
||||
os: Operating system ('win', 'mac', or 'lin')
|
||||
vendor: Optional specific vendor to use
|
||||
renderer: Optional specific renderer to use (requires vendor to be set)
|
||||
|
||||
Returns:
|
||||
Dict containing WebGL data including vendor, renderer and additional parameters
|
||||
|
||||
Raises:
|
||||
ValueError: If invalid OS provided or no data found for OS/vendor/renderer
|
||||
"""
|
||||
# Check that the OS is valid (avoid SQL injection)
|
||||
if os not in OS_ARCH_MATRIX:
|
||||
raise ValueError(f'Invalid OS: {os}. Must be one of: win, mac, lin')
|
||||
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if vendor and renderer:
|
||||
# Get specific vendor/renderer pair and verify it exists for this OS
|
||||
cursor.execute(
|
||||
f'SELECT vendor, renderer, data, {os} FROM webgl_fingerprints ' # nosec
|
||||
'WHERE vendor = ? AND renderer = ?',
|
||||
(vendor, renderer),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
|
||||
if not result:
|
||||
raise ValueError(f'No WebGL data found for vendor "{vendor}" and renderer "{renderer}"')
|
||||
|
||||
if result[3] <= 0: # Check OS-specific probability
|
||||
# Get a list of possible (vendor, renderer) pairs for this OS
|
||||
cursor.execute(
|
||||
f'SELECT DISTINCT vendor, renderer FROM webgl_fingerprints WHERE {os} > 0' # nosec
|
||||
)
|
||||
possible_pairs = cursor.fetchall()
|
||||
raise ValueError(
|
||||
f'Vendor "{vendor}" and renderer "{renderer}" combination not valid for {os.title()}.\n'
|
||||
f'Possible pairs: {", ".join(str(pair) for pair in possible_pairs)}'
|
||||
)
|
||||
|
||||
return _load_webgl_data(result[2], os)
|
||||
|
||||
# Get all vendor/renderer pairs and their probabilities for this OS
|
||||
cursor.execute(
|
||||
f'SELECT vendor, renderer, data, {os} FROM webgl_fingerprints WHERE {os} > 0' # nosec
|
||||
)
|
||||
results = cursor.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not results:
|
||||
raise ValueError(f'No WebGL data found for OS: {os}')
|
||||
|
||||
# Drop pairs this OS cannot report before sampling. webgl_data.db weights
|
||||
# each pair per OS, and its macOS column once carried a Braswell Atom IGP
|
||||
# ("Intel(R) HD Graphics 400") at 7.4% and a desktop PC card ("Radeon R9 200
|
||||
# Series") at 3.7% -- neither shipped in any Mac, so ~11% of macOS
|
||||
# identities were drawing a GPU that would contradict the rest of the
|
||||
# identity the moment a page read the renderer string beside the platform.
|
||||
# Filtering here rather than repairing later keeps reported and sampled
|
||||
# WebGL parameters from the same recorded device.
|
||||
from ..coherence import gpu_fits_os
|
||||
|
||||
coherent = [row for row in results if gpu_fits_os(row[1], os)]
|
||||
if coherent:
|
||||
results = coherent
|
||||
|
||||
# Split into separate arrays
|
||||
_, _, data_strs, probs = map(list, zip(*results))
|
||||
|
||||
# Convert probabilities to numpy array and normalize
|
||||
probs_array = np.array(probs, dtype=np.float64)
|
||||
probs_array = probs_array / probs_array.sum()
|
||||
|
||||
# Sample based on probabilities
|
||||
# Seeded so the same identity always draws the same device (#442/#765).
|
||||
idx = np.random.default_rng(seed).choice(len(probs_array), p=probs_array)
|
||||
|
||||
# Parse the JSON data string
|
||||
return _load_webgl_data(data_strs[idx], os)
|
||||
|
||||
|
||||
def get_possible_pairs() -> Dict[str, List[Tuple[str, str]]]:
|
||||
"""
|
||||
Get all possible (vendor, renderer) pairs for all OS, where the probability is greater than 0.
|
||||
"""
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all vendor/renderer pairs for each OS where probability > 0
|
||||
result: Dict[str, List[Tuple[str, str]]] = {}
|
||||
for os_type in OS_ARCH_MATRIX:
|
||||
cursor.execute(
|
||||
'SELECT DISTINCT vendor, renderer FROM webgl_fingerprints '
|
||||
f'WHERE {os_type} > 0 ORDER BY {os_type} DESC', # nosec
|
||||
)
|
||||
result[os_type] = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
Binary file not shown.
@@ -0,0 +1,567 @@
|
||||
{
|
||||
"webGl:renderer": "NVIDIA GeForce GTX 980, or similar",
|
||||
"webGl:vendor": "NVIDIA Corporation",
|
||||
"webGl:contextAttributes": {
|
||||
"alpha": true,
|
||||
"antialias": true,
|
||||
"depth": true,
|
||||
"failIfMajorPerformanceCaveat": false,
|
||||
"powerPreference": "default",
|
||||
"premultipliedAlpha": true,
|
||||
"preserveDrawingBuffer": false,
|
||||
"stencil": false
|
||||
},
|
||||
"webGl:supportedExtensions": [
|
||||
"ANGLE_instanced_arrays",
|
||||
"EXT_blend_minmax",
|
||||
"EXT_color_buffer_half_float",
|
||||
"EXT_depth_clamp",
|
||||
"EXT_float_blend",
|
||||
"EXT_frag_depth",
|
||||
"EXT_shader_texture_lod",
|
||||
"EXT_sRGB",
|
||||
"EXT_texture_compression_bptc",
|
||||
"EXT_texture_compression_rgtc",
|
||||
"EXT_texture_filter_anisotropic",
|
||||
"OES_element_index_uint",
|
||||
"OES_fbo_render_mipmap",
|
||||
"OES_standard_derivatives",
|
||||
"OES_texture_float",
|
||||
"OES_texture_float_linear",
|
||||
"OES_texture_half_float",
|
||||
"OES_texture_half_float_linear",
|
||||
"OES_vertex_array_object",
|
||||
"WEBGL_color_buffer_float",
|
||||
"WEBGL_compressed_texture_etc",
|
||||
"WEBGL_compressed_texture_s3tc",
|
||||
"WEBGL_compressed_texture_s3tc_srgb",
|
||||
"WEBGL_debug_renderer_info",
|
||||
"WEBGL_debug_shaders",
|
||||
"WEBGL_depth_texture",
|
||||
"WEBGL_draw_buffers",
|
||||
"WEBGL_lose_context"
|
||||
],
|
||||
"webGl:parameters": {
|
||||
"2849": 1,
|
||||
"2884": false,
|
||||
"2885": 1029,
|
||||
"2886": 2305,
|
||||
"2928": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"2929": false,
|
||||
"2930": true,
|
||||
"2931": 1,
|
||||
"2932": 513,
|
||||
"2960": false,
|
||||
"2961": 0,
|
||||
"2962": 519,
|
||||
"2963": 4294967295,
|
||||
"2964": 7680,
|
||||
"2965": 7680,
|
||||
"2966": 7680,
|
||||
"2967": 0,
|
||||
"2968": 4294967295,
|
||||
"2978": [
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
150
|
||||
],
|
||||
"3024": true,
|
||||
"3042": false,
|
||||
"3074": null,
|
||||
"3088": [
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
150
|
||||
],
|
||||
"3089": false,
|
||||
"3106": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"3107": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
],
|
||||
"3314": null,
|
||||
"3315": null,
|
||||
"3316": null,
|
||||
"3317": 4,
|
||||
"3330": null,
|
||||
"3331": null,
|
||||
"3332": null,
|
||||
"3333": 4,
|
||||
"3379": 32768,
|
||||
"3386": [
|
||||
32768,
|
||||
32768
|
||||
],
|
||||
"3408": 8,
|
||||
"3410": 8,
|
||||
"3411": 8,
|
||||
"3412": 8,
|
||||
"3413": 8,
|
||||
"3414": 24,
|
||||
"3415": 0,
|
||||
"7936": "Mozilla",
|
||||
"7937": "NVIDIA GeForce GTX 980, or similar",
|
||||
"7938": "WebGL 1.0",
|
||||
"10752": 0,
|
||||
"32773": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"32777": 32774,
|
||||
"32823": false,
|
||||
"32824": 0,
|
||||
"32873": null,
|
||||
"32877": null,
|
||||
"32878": null,
|
||||
"32883": null,
|
||||
"32926": false,
|
||||
"32928": false,
|
||||
"32936": 1,
|
||||
"32937": 4,
|
||||
"32938": 1,
|
||||
"32939": false,
|
||||
"32968": 0,
|
||||
"32969": 1,
|
||||
"32970": 0,
|
||||
"32971": 1,
|
||||
"33000": null,
|
||||
"33001": null,
|
||||
"33170": 4352,
|
||||
"33901": [
|
||||
1,
|
||||
2047
|
||||
],
|
||||
"33902": [
|
||||
1,
|
||||
10
|
||||
],
|
||||
"34016": 33984,
|
||||
"34024": 32768,
|
||||
"34045": null,
|
||||
"34047": null,
|
||||
"34068": null,
|
||||
"34076": 32768,
|
||||
"34467": null,
|
||||
"34816": 519,
|
||||
"34817": 7680,
|
||||
"34818": 7680,
|
||||
"34819": 7680,
|
||||
"34852": null,
|
||||
"34853": null,
|
||||
"34854": null,
|
||||
"34855": null,
|
||||
"34856": null,
|
||||
"34857": null,
|
||||
"34858": null,
|
||||
"34859": null,
|
||||
"34860": null,
|
||||
"34877": 32774,
|
||||
"34921": 16,
|
||||
"34930": 32,
|
||||
"34964": null,
|
||||
"34965": null,
|
||||
"35071": null,
|
||||
"35076": null,
|
||||
"35077": null,
|
||||
"35371": null,
|
||||
"35373": null,
|
||||
"35374": null,
|
||||
"35375": null,
|
||||
"35376": null,
|
||||
"35377": null,
|
||||
"35379": null,
|
||||
"35380": null,
|
||||
"35657": null,
|
||||
"35658": null,
|
||||
"35659": null,
|
||||
"35660": 32,
|
||||
"35661": 192,
|
||||
"35723": null,
|
||||
"35724": "WebGL GLSL ES 1.0",
|
||||
"35725": null,
|
||||
"35738": 5121,
|
||||
"35739": 6408,
|
||||
"35968": null,
|
||||
"35977": null,
|
||||
"35978": null,
|
||||
"35979": null,
|
||||
"36003": 0,
|
||||
"36004": 4294967295,
|
||||
"36005": 4294967295,
|
||||
"36006": null,
|
||||
"36007": null,
|
||||
"36063": null,
|
||||
"36183": null,
|
||||
"36203": null,
|
||||
"36345": null,
|
||||
"36347": 1024,
|
||||
"36348": 32,
|
||||
"36349": 1024,
|
||||
"36387": null,
|
||||
"36388": null,
|
||||
"36392": null,
|
||||
"36795": null,
|
||||
"37137": null,
|
||||
"37154": null,
|
||||
"37157": null,
|
||||
"37440": false,
|
||||
"37441": false,
|
||||
"37443": 37444,
|
||||
"37444": null,
|
||||
"37445": "NVIDIA Corporation",
|
||||
"37446": "NVIDIA GeForce GTX 980, or similar",
|
||||
"37447": null,
|
||||
"38449": null
|
||||
},
|
||||
"webGl:shaderPrecisionFormats": {
|
||||
"35633,36336": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35633,36337": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35633,36338": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35633,36339": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
},
|
||||
"35633,36340": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
},
|
||||
"35633,36341": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
},
|
||||
"35632,36336": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35632,36337": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35632,36338": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35632,36339": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
},
|
||||
"35632,36340": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
},
|
||||
"35632,36341": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
}
|
||||
},
|
||||
"webGl2:contextAttributes": {
|
||||
"alpha": true,
|
||||
"antialias": true,
|
||||
"depth": true,
|
||||
"failIfMajorPerformanceCaveat": false,
|
||||
"powerPreference": "default",
|
||||
"premultipliedAlpha": true,
|
||||
"preserveDrawingBuffer": false,
|
||||
"stencil": false
|
||||
},
|
||||
"webGl2:supportedExtensions": [
|
||||
"EXT_color_buffer_float",
|
||||
"EXT_depth_clamp",
|
||||
"EXT_float_blend",
|
||||
"EXT_texture_compression_bptc",
|
||||
"EXT_texture_compression_rgtc",
|
||||
"EXT_texture_filter_anisotropic",
|
||||
"OES_draw_buffers_indexed",
|
||||
"OES_texture_float_linear",
|
||||
"WEBGL_compressed_texture_etc",
|
||||
"WEBGL_compressed_texture_s3tc",
|
||||
"WEBGL_compressed_texture_s3tc_srgb",
|
||||
"WEBGL_debug_renderer_info",
|
||||
"WEBGL_debug_shaders",
|
||||
"WEBGL_lose_context"
|
||||
],
|
||||
"webGl2:parameters": {
|
||||
"2849": 1,
|
||||
"2884": false,
|
||||
"2885": 1029,
|
||||
"2886": 2305,
|
||||
"2928": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"2929": false,
|
||||
"2930": true,
|
||||
"2931": 1,
|
||||
"2932": 513,
|
||||
"2960": false,
|
||||
"2961": 0,
|
||||
"2962": 519,
|
||||
"2963": 4294967295,
|
||||
"2964": 7680,
|
||||
"2965": 7680,
|
||||
"2966": 7680,
|
||||
"2967": 0,
|
||||
"2968": 4294967295,
|
||||
"2978": [
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
150
|
||||
],
|
||||
"3024": true,
|
||||
"3042": false,
|
||||
"3074": 1029,
|
||||
"3088": [
|
||||
0,
|
||||
0,
|
||||
300,
|
||||
150
|
||||
],
|
||||
"3089": false,
|
||||
"3106": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"3107": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
],
|
||||
"3314": 0,
|
||||
"3315": 0,
|
||||
"3316": 0,
|
||||
"3317": 4,
|
||||
"3330": 0,
|
||||
"3331": 0,
|
||||
"3332": 0,
|
||||
"3333": 4,
|
||||
"3379": 32768,
|
||||
"3386": [
|
||||
32768,
|
||||
32768
|
||||
],
|
||||
"3408": 8,
|
||||
"3410": 8,
|
||||
"3411": 8,
|
||||
"3412": 8,
|
||||
"3413": 8,
|
||||
"3414": 24,
|
||||
"3415": 0,
|
||||
"7936": "Mozilla",
|
||||
"7937": "NVIDIA GeForce GTX 980, or similar",
|
||||
"7938": "WebGL 2.0",
|
||||
"10752": 0,
|
||||
"32773": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"32777": 32774,
|
||||
"32823": false,
|
||||
"32824": 0,
|
||||
"32873": null,
|
||||
"32877": 0,
|
||||
"32878": 0,
|
||||
"32883": 16384,
|
||||
"32926": false,
|
||||
"32928": false,
|
||||
"32936": 1,
|
||||
"32937": 4,
|
||||
"32938": 1,
|
||||
"32939": false,
|
||||
"32968": 0,
|
||||
"32969": 1,
|
||||
"32970": 0,
|
||||
"32971": 1,
|
||||
"33000": 1048576,
|
||||
"33001": 1048576,
|
||||
"33170": 4352,
|
||||
"33901": [
|
||||
1,
|
||||
2047
|
||||
],
|
||||
"33902": [
|
||||
1,
|
||||
1
|
||||
],
|
||||
"34016": 33984,
|
||||
"34024": 32768,
|
||||
"34045": 15,
|
||||
"34047": null,
|
||||
"34068": null,
|
||||
"34076": 32768,
|
||||
"34467": null,
|
||||
"34816": 519,
|
||||
"34817": 7680,
|
||||
"34818": 7680,
|
||||
"34819": 7680,
|
||||
"34852": 8,
|
||||
"34853": 1029,
|
||||
"34854": 0,
|
||||
"34855": 0,
|
||||
"34856": 0,
|
||||
"34857": 0,
|
||||
"34858": 0,
|
||||
"34859": 0,
|
||||
"34860": 0,
|
||||
"34877": 32774,
|
||||
"34921": 16,
|
||||
"34930": 32,
|
||||
"34964": null,
|
||||
"34965": null,
|
||||
"35071": 2048,
|
||||
"35076": -8,
|
||||
"35077": 7,
|
||||
"35371": 14,
|
||||
"35373": 14,
|
||||
"35374": 84,
|
||||
"35375": 84,
|
||||
"35376": 65536,
|
||||
"35377": 233472,
|
||||
"35379": 233472,
|
||||
"35380": 256,
|
||||
"35657": 4096,
|
||||
"35658": 4096,
|
||||
"35659": 124,
|
||||
"35660": 32,
|
||||
"35661": 192,
|
||||
"35723": 4352,
|
||||
"35724": "WebGL GLSL ES 3.00",
|
||||
"35725": null,
|
||||
"35738": 5121,
|
||||
"35739": 6408,
|
||||
"35968": 4,
|
||||
"35977": false,
|
||||
"35978": 128,
|
||||
"35979": 4,
|
||||
"36003": 0,
|
||||
"36004": 4294967295,
|
||||
"36005": 4294967295,
|
||||
"36006": null,
|
||||
"36007": null,
|
||||
"36063": 8,
|
||||
"36183": 32,
|
||||
"36203": 4294967295,
|
||||
"36345": null,
|
||||
"36347": 1024,
|
||||
"36348": 32,
|
||||
"36349": 1024,
|
||||
"36387": false,
|
||||
"36388": false,
|
||||
"36392": null,
|
||||
"36795": null,
|
||||
"37137": 1.8446744073709552e+19,
|
||||
"37154": 128,
|
||||
"37157": 128,
|
||||
"37440": false,
|
||||
"37441": false,
|
||||
"37443": 37444,
|
||||
"37444": null,
|
||||
"37445": "NVIDIA Corporation",
|
||||
"37446": "NVIDIA GeForce GTX 980, or similar",
|
||||
"37447": 1000000000,
|
||||
"38449": null
|
||||
},
|
||||
"webGl2:shaderPrecisionFormats": {
|
||||
"35633,36336": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35633,36337": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35633,36338": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35633,36339": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
},
|
||||
"35633,36340": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
},
|
||||
"35633,36341": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
},
|
||||
"35632,36336": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35632,36337": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35632,36338": {
|
||||
"rangeMin": 127,
|
||||
"rangeMax": 127,
|
||||
"precision": 23
|
||||
},
|
||||
"35632,36339": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
},
|
||||
"35632,36340": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
},
|
||||
"35632,36341": {
|
||||
"rangeMin": 24,
|
||||
"rangeMax": 24,
|
||||
"precision": 0
|
||||
}
|
||||
},
|
||||
"webGl2Enabled": true
|
||||
}
|
||||
@@ -5,8 +5,6 @@ a page can see. Each site warns with a report block the user can paste into a
|
||||
GitHub issue, so the failure reaches us instead of shipping silently.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from test_identity_salt import launch
|
||||
|
||||
@@ -56,7 +54,6 @@ def test_preset_voice_draw(monkeypatch):
|
||||
[
|
||||
("_generate_random_font_subset", OSError("fonts.json missing"), "fonts"),
|
||||
("_generate_random_voice_subset", ValueError("bad manifest"), "voices"),
|
||||
("sample_webgl_for_screen", sqlite3.OperationalError("no such table"), "webGl:renderer"),
|
||||
],
|
||||
)
|
||||
def test_context_draws(monkeypatch, target, error, key):
|
||||
@@ -83,14 +80,3 @@ def test_launch_font_draw(monkeypatch):
|
||||
with pytest.warns(FallbackWarning, match=REPORT):
|
||||
config = launch()
|
||||
assert config["fonts"]
|
||||
|
||||
|
||||
def test_preset_gpu_not_in_webgl_data():
|
||||
# Windows reports every GPU through ANGLE, so only an ANGLE string reaches the lookup.
|
||||
GPU = "ANGLE (Acme, Acme GPU 9000 Direct3D11 vs_5_0 ps_5_0)"
|
||||
preset = fp.load_presets("152")["presets"]["windows"][0]
|
||||
preset = {**preset, "webgl": {"unmaskedVendor": "Google Inc. (Acme)", "unmaskedRenderer": GPU}}
|
||||
with pytest.warns(FallbackWarning, match=REPORT) as record:
|
||||
config = launch(os="windows", fingerprint_preset=preset)
|
||||
assert GPU in _report(record)
|
||||
assert config["webGl:renderer"] != GPU
|
||||
|
||||
@@ -83,23 +83,13 @@ class TestPinnedIdentityIsStable:
|
||||
@pytest.mark.parametrize("os_name", ["windows", "macos", "linux"])
|
||||
def test_every_bundled_preset_launches(self, os_name):
|
||||
# The test above draws ONE preset at random, so a preset that cannot
|
||||
# launch shows up as a 1-in-11 flake rather than a failure -- which is
|
||||
# how it reached CI. 39 of the 435 bundled presets name a GPU that is
|
||||
# not among the 31 in webgl_data.db, and sample_webgl raises for those.
|
||||
# Every preset has to produce launch options; see the fallback in
|
||||
# utils.launch_options.
|
||||
from camoufox.webgl import sample_webgl
|
||||
|
||||
# launch would show up as a flake rather than a failure. Every preset
|
||||
# must launch with its own GPU and that GPU's recorded parameters.
|
||||
presets = fp.load_presets("150")["presets"][os_name]
|
||||
key = {"windows": "win", "macos": "mac", "linux": "lin"}[os_name]
|
||||
for i, preset in enumerate(presets):
|
||||
config = launch(os=os_name, fingerprint_preset=preset)
|
||||
# Whatever GPU survives, the renderer the page reads and the
|
||||
# parameters behind it must come from the SAME recorded device --
|
||||
# merge_into does not overwrite, so a fallback that forgets to drop
|
||||
# the preset's pair leaves one device's name on another's data.
|
||||
assert config["webGl:renderer"] == preset["webgl"]["unmaskedRenderer"], (os_name, i)
|
||||
assert config.get("webGl:parameters"), (os_name, i)
|
||||
sample_webgl(key, config["webGl:vendor"], config["webGl:renderer"])
|
||||
|
||||
def test_caller_seed_is_kept(self):
|
||||
assert launch(config={"audio:seed": 9})["audio:seed"] == 9
|
||||
|
||||
@@ -9,11 +9,10 @@ rules -- `scripts/clean-fingerprint-data.py --write` is what makes these pass.
|
||||
Dropped on 2026-09-17: 38 of 435 presets (26 with a GPU their OS cannot report,
|
||||
7 pairing Apple Silicon with a core count Apple never shipped, 4 with a colour
|
||||
depth their GPU contradicts, 3 with a phone viewport, 1 claiming 40 touch
|
||||
points), and 2 impossible macOS weights in webgl_data.db.
|
||||
points).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from os.path import dirname, join
|
||||
from pathlib import Path
|
||||
@@ -24,7 +23,6 @@ sys.path.insert(0, join(dirname(__file__), ".."))
|
||||
|
||||
from camoufox import coherence # noqa: E402
|
||||
from camoufox.fingerprints import from_preset # noqa: E402
|
||||
from camoufox.webgl.sample import DB_PATH # noqa: E402
|
||||
|
||||
DATA = Path(__file__).parent.parent / "camoufox"
|
||||
PRESET_FILES = ("fingerprint-presets.json", "fingerprint-presets-v150.json")
|
||||
@@ -46,62 +44,12 @@ def test_every_bundled_preset_is_coherent_as_stored(filename):
|
||||
)
|
||||
|
||||
|
||||
def test_no_gpu_is_offered_to_an_os_that_cannot_report_it():
|
||||
connection = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT vendor, renderer, win, mac, lin FROM webgl_fingerprints"
|
||||
).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
assert rows, "webgl_data.db is empty"
|
||||
for vendor, renderer, *weights in rows:
|
||||
for os_key, weight in zip(("win", "mac", "lin"), weights):
|
||||
if weight and weight > 0:
|
||||
assert coherence.gpu_fits_os(renderer, os_key), (
|
||||
f"{renderer!r} is offered to {os_key} at {weight}"
|
||||
)
|
||||
|
||||
|
||||
def test_each_os_still_has_gpus_to_draw_from():
|
||||
"""The filter must not empty a pool -- a single GPU per OS is its own tell."""
|
||||
connection = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
for os_key in ("win", "mac", "lin"):
|
||||
count = connection.execute(
|
||||
f"SELECT COUNT(*) FROM webgl_fingerprints WHERE {os_key} > 0" # nosec
|
||||
).fetchone()[0]
|
||||
assert count >= 2, f"{os_key} has {count} GPU(s) left"
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def test_every_gpu_row_can_be_drawn_on_some_os():
|
||||
"""A row no OS can draw is dead weight a reader would take as real data."""
|
||||
connection = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
rows = connection.execute(
|
||||
"SELECT vendor, renderer, win, mac, lin FROM webgl_fingerprints"
|
||||
).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
undrawable = [
|
||||
(vendor, renderer)
|
||||
for vendor, renderer, *weights in rows
|
||||
if not any(
|
||||
weight > 0 and coherence.gpu_fits_os(renderer, os_key)
|
||||
for os_key, weight in zip(("win", "mac", "lin"), weights)
|
||||
)
|
||||
]
|
||||
assert undrawable == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filename", PRESET_FILES)
|
||||
def test_every_preset_gpu_has_webgl_data(filename):
|
||||
"""A preset records only its GPU's name; the WebGL parameters behind it come
|
||||
from fpgen. A GPU fpgen has never seen Firefox report on that OS has none, so
|
||||
launching it would pair the name with another device's parameters."""
|
||||
from camoufox.fingerprints import firefox_gpus
|
||||
from camoufox.webgl import firefox_gpus
|
||||
|
||||
presets = json.loads((DATA / filename).read_text())["presets"]
|
||||
for os_name, entries in presets.items():
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""WebGL identities drawn from fpgen's recorded Firefox devices (camoufox.webgl)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from test_identity_salt import host, launch
|
||||
|
||||
from camoufox import coherence, utils
|
||||
from camoufox import fingerprints as fp
|
||||
from camoufox import webgl
|
||||
from camoufox.fingerprints import gpu_screen_is_plausible, is_software_renderer
|
||||
from camoufox.webgl import sample_webgl_for_screen, webgl_for_gpu
|
||||
|
||||
SEEDS = range(300)
|
||||
OSES = ("win", "mac", "lin")
|
||||
|
||||
_GTX_980_LINUX = ("NVIDIA Corporation", "NVIDIA GeForce GTX 980, or similar")
|
||||
_BASIC_RENDER_DRIVER = (
|
||||
"Google Inc. (Microsoft)",
|
||||
"ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0), or similar",
|
||||
)
|
||||
|
||||
# Limits WebGL1 and WebGL2 read from the same device: MAX_TEXTURE_SIZE,
|
||||
# MAX_VIEWPORT_DIMS, MAX_RENDERBUFFER_SIZE, MAX_CUBE_MAP_TEXTURE_SIZE,
|
||||
# MAX_VERTEX_ATTRIBS, MAX_TEXTURE_IMAGE_UNITS, MAX_VERTEX_TEXTURE_IMAGE_UNITS,
|
||||
# MAX_COMBINED_TEXTURE_IMAGE_UNITS and the three uniform/varying vector limits.
|
||||
_SHARED_LIMITS = ("3379", "3386", "34024", "34076", "34921", "34930", "35660", "35661", "36347", "36348", "36349")
|
||||
|
||||
|
||||
def _as_json(value):
|
||||
# JSON has one number type: 2**64 stored as an int and as a float is the
|
||||
# same value to the browser's parser.
|
||||
return json.loads(json.dumps(value), parse_int=float)
|
||||
|
||||
|
||||
def test_converter_reproduces_the_recorded_device():
|
||||
"""The fixture is what the retired webgl_data.db gave launch_options for
|
||||
this GPU. fpgen records the same device, so everything the browser reads
|
||||
must come out identical: parameters are compared where the old row had a
|
||||
value, less the UNMASKED_* strings, which the browser takes from
|
||||
webGl:vendor/renderer rather than the table."""
|
||||
old = json.loads((Path(__file__).parent / "data" / "webgl-gtx980-linux.json").read_text())
|
||||
new = webgl_for_gpu("lin", *_GTX_980_LINUX, seed=0)
|
||||
|
||||
assert new.keys() == old.keys()
|
||||
for key in old:
|
||||
if key.endswith(":parameters"):
|
||||
recorded = {
|
||||
pname: value
|
||||
for pname, value in old[key].items()
|
||||
if value is not None and pname not in ("37445", "37446")
|
||||
}
|
||||
assert _as_json({pname: new[key].get(pname) for pname in recorded}) == _as_json(recorded), key
|
||||
else:
|
||||
assert new[key] == old[key], key
|
||||
|
||||
|
||||
def test_same_seed_same_device():
|
||||
for target_os in OSES:
|
||||
for seed in (0, 1, 12345):
|
||||
assert sample_webgl_for_screen(target_os, 1920, 1080, seed) == sample_webgl_for_screen(
|
||||
target_os, 1920, 1080, seed
|
||||
)
|
||||
gpu = ("AMD", "Radeon R9 200 Series, or similar")
|
||||
assert webgl_for_gpu("lin", *gpu, seed=7) == webgl_for_gpu("lin", *gpu, seed=7)
|
||||
|
||||
|
||||
def test_seed_chooses_among_a_gpus_recorded_devices():
|
||||
# fpgen records several Linux R9 200 devices; one seed must not pin them all.
|
||||
drawn = {json.dumps(webgl_for_gpu("lin", "AMD", "Radeon R9 200 Series, or similar", seed=s)) for s in range(40)}
|
||||
assert len(drawn) > 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_os", OSES)
|
||||
def test_synthetic_draw_is_a_hardware_gpu_the_os_reports(target_os):
|
||||
renderers = {sample_webgl_for_screen(target_os, 1920, 1080, s)["webGl:renderer"] for s in SEEDS}
|
||||
for renderer in renderers:
|
||||
assert not is_software_renderer(renderer), renderer
|
||||
assert renderer != "Mozilla"
|
||||
assert coherence.gpu_fits_os(renderer, target_os), renderer
|
||||
# A single GPU per OS is its own tell.
|
||||
assert len(renderers) >= 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_os", OSES)
|
||||
def test_netbook_screen_never_draws_a_discrete_gpu(target_os):
|
||||
for seed in SEEDS:
|
||||
renderer = sample_webgl_for_screen(target_os, 1024, 600, seed)["webGl:renderer"]
|
||||
assert gpu_screen_is_plausible(renderer, 1024, 600), renderer
|
||||
|
||||
|
||||
def test_no_coherent_gpu_raises(monkeypatch):
|
||||
# A pool with nothing that fits is a data defect; substituting a GPU the
|
||||
# filters rejected would present exactly what they exist to prevent.
|
||||
only_software = tuple(r for r in webgl._trace("gpu", "lin") if is_software_renderer(r.value["renderer"]))
|
||||
assert only_software
|
||||
monkeypatch.setattr(webgl, "_trace", lambda *a, **kw: only_software)
|
||||
with pytest.raises(ValueError, match="No recorded lin GPU"):
|
||||
sample_webgl_for_screen("lin", 1920, 1080, seed=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_os", OSES)
|
||||
def test_webgl2_comes_from_the_same_device_as_webgl1(target_os):
|
||||
# webgl2 is pinned to the drawn webgl; drawn on the GPU alone, a Linux
|
||||
# Intel identity paired MAX_TEXTURE_SIZE 8192 with 16384.
|
||||
for seed in SEEDS:
|
||||
config = sample_webgl_for_screen(target_os, 1920, 1080, seed)
|
||||
for pname in _SHARED_LIMITS:
|
||||
assert config["webGl:parameters"][pname] == config["webGl2:parameters"][pname], (seed, pname)
|
||||
|
||||
|
||||
def test_gpu_is_pinned_by_vendor_and_renderer():
|
||||
# "Mesa" and "AMD" both report this renderer on Linux. A dict condition on
|
||||
# fpgen matches the renderer alone and mixed their devices.
|
||||
for seed in range(40):
|
||||
assert webgl_for_gpu("lin", "Mesa", "Radeon HD 3200 Graphics, or similar", seed)["webGl:vendor"] == "Mesa"
|
||||
|
||||
|
||||
def test_device_without_webgl2():
|
||||
config = webgl_for_gpu("win", *_BASIC_RENDER_DRIVER, seed=0)
|
||||
assert config["webGl2Enabled"] is False
|
||||
assert not any(key.startswith("webGl2:") for key in config)
|
||||
|
||||
with host():
|
||||
options = utils.launch_options(
|
||||
os="windows", webgl_config=_BASIC_RENDER_DRIVER, headless=True, i_know_what_im_doing=True
|
||||
)
|
||||
assert options["firefox_user_prefs"]["webgl.enable-webgl2"] is False
|
||||
|
||||
|
||||
def test_unknown_webgl_config_raises():
|
||||
with pytest.raises(ValueError, match="No recorded WebGL data"):
|
||||
launch(os="windows", webgl_config=("Apple", "Apple M1, or similar"))
|
||||
|
||||
|
||||
def test_preset_keeps_its_own_gpu():
|
||||
preset = fp.load_presets("152")["presets"]["linux"][0]
|
||||
gpu = (preset["webgl"]["unmaskedVendor"], preset["webgl"]["unmaskedRenderer"])
|
||||
config = launch(os="linux", fingerprint_preset=preset)
|
||||
assert (config["webGl:vendor"], config["webGl:renderer"]) == gpu
|
||||
pinned = (webgl._pin("gpu", {"vendor": gpu[0], "renderer": gpu[1]}),)
|
||||
recorded = [webgl.to_config(r.value, [], "lin")["webGl:parameters"] for r in webgl._trace("webgl", "lin", pinned)]
|
||||
assert config["webGl:parameters"] in recorded
|
||||
|
||||
|
||||
def test_preset_gpu_fpgen_has_never_seen_raises():
|
||||
preset = fp.load_presets("152")["presets"]["windows"][0]
|
||||
gpu = "ANGLE (Acme, Acme GPU 9000 Direct3D11 vs_5_0 ps_5_0)"
|
||||
preset = {**preset, "webgl": {"unmaskedVendor": "Google Inc. (Acme)", "unmaskedRenderer": gpu}}
|
||||
with pytest.raises(ValueError, match="Acme GPU 9000"):
|
||||
launch(os="windows", fingerprint_preset=preset)
|
||||
|
||||
|
||||
# -- extensions ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _extensions(target_os, key):
|
||||
return [
|
||||
set(sample_webgl_for_screen(target_os, 1920, 1080, s).get(key) or ()) for s in range(100)
|
||||
]
|
||||
|
||||
|
||||
def test_windows_keeps_ovr_multiview2_on_webgl2():
|
||||
assert any("OVR_multiview2" in exts for exts in _extensions("win", "webGl2:supportedExtensions"))
|
||||
|
||||
|
||||
def test_linux_filters_ovr_multiview2():
|
||||
# fpgen records it on ~20% of Linux WebGL2 devices.
|
||||
assert not any("OVR_multiview2" in exts for exts in _extensions("lin", "webGl2:supportedExtensions"))
|
||||
|
||||
|
||||
def test_draft_extensions_filtered_on_every_os():
|
||||
recorded = {
|
||||
"vendor": "v",
|
||||
"renderer": "r",
|
||||
"contextAttributes": {},
|
||||
"params": {},
|
||||
"shaderPrecisionFormats": [],
|
||||
"supportedExtensions": ["ANGLE_instanced_arrays", "WEBGL_multi_draw", "WEBGL_compressed_texture_etc1"],
|
||||
}
|
||||
webgl2 = {**recorded, "supportedExtensions": ["EXT_texture_norm16", "WEBGL_clip_cull_distance", "OVR_multiview2"]}
|
||||
for target_os in OSES:
|
||||
config = webgl.to_config(recorded, webgl2, target_os)
|
||||
assert config["webGl:supportedExtensions"] == ["ANGLE_instanced_arrays"]
|
||||
assert config["webGl2:supportedExtensions"] == (["OVR_multiview2"] if target_os == "win" else [])
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Which sampled WebGL extensions reach the page, per OS."""
|
||||
|
||||
import sqlite3
|
||||
|
||||
import orjson
|
||||
|
||||
from camoufox.webgl import sample
|
||||
from camoufox.webgl.sample import DB_PATH, _load_webgl_data
|
||||
|
||||
|
||||
def _row_with(ext, key="webGl2:supportedExtensions", os="win"):
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
for (data,) in con.execute(f"SELECT data FROM webgl_fingerprints WHERE {os} > 0"): # nosec
|
||||
if ext in (orjson.loads(data).get(key) or []):
|
||||
return data
|
||||
finally:
|
||||
con.close()
|
||||
raise AssertionError(f"no {os} row carries {ext}")
|
||||
|
||||
|
||||
def test_windows_keeps_ovr_multiview2_on_webgl2():
|
||||
data = _load_webgl_data(_row_with("OVR_multiview2"), "win")
|
||||
assert "OVR_multiview2" in data["webGl2:supportedExtensions"]
|
||||
|
||||
|
||||
def test_linux_filters_ovr_multiview2():
|
||||
data = _load_webgl_data(_row_with("OVR_multiview2", os="lin"), "lin")
|
||||
assert "OVR_multiview2" not in data["webGl2:supportedExtensions"]
|
||||
|
||||
|
||||
def test_ovr_multiview2_never_on_webgl1_in_corpus():
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
try:
|
||||
for (data,) in con.execute("SELECT data FROM webgl_fingerprints"):
|
||||
assert "OVR_multiview2" not in (orjson.loads(data).get("webGl:supportedExtensions") or [])
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def test_draft_extensions_filtered_on_every_os():
|
||||
blob = orjson.dumps(
|
||||
{
|
||||
"webGl:supportedExtensions": ["ANGLE_instanced_arrays", "WEBGL_multi_draw"],
|
||||
"webGl2:supportedExtensions": ["EXT_texture_norm16", "WEBGL_clip_cull_distance", "OVR_multiview2"],
|
||||
}
|
||||
)
|
||||
for os in ("win", "mac", "lin"):
|
||||
data = _load_webgl_data(blob, os)
|
||||
assert data["webGl:supportedExtensions"] == ["ANGLE_instanced_arrays"]
|
||||
assert "EXT_texture_norm16" not in data["webGl2:supportedExtensions"]
|
||||
assert "WEBGL_clip_cull_distance" not in data["webGl2:supportedExtensions"]
|
||||
|
||||
|
||||
def test_sampled_windows_identities_can_carry_it():
|
||||
hits = sum(
|
||||
"OVR_multiview2" in (sample.sample_webgl("win", seed=s).get("webGl2:supportedExtensions") or [])
|
||||
for s in range(200)
|
||||
)
|
||||
assert hits > 0
|
||||
@@ -4,8 +4,8 @@ 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
|
||||
The regression these guard (daijro/camoufox#729): the generator picks the
|
||||
screen, camoufox.webgl 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).
|
||||
|
||||
@@ -24,15 +24,14 @@ 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,
|
||||
)
|
||||
from camoufox.webgl import sample_webgl_for_screen # noqa: E402
|
||||
|
||||
# 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"
|
||||
@@ -153,64 +152,10 @@ def test_hardware_is_not_mistaken_for_software(monkeypatch):
|
||||
assert not is_software_renderer(renderer)
|
||||
|
||||
|
||||
def test_software_first_draw_is_resampled_to_hardware(monkeypatch):
|
||||
"""A presented llvmpipe / WARP / SwiftShader is the first thing every
|
||||
consumer-hardware check flags (measured 2026-09-14 with sundial), so a
|
||||
software first draw is retried until a hardware renderer that fits the
|
||||
screen comes up, and only kept when the pool offers nothing else."""
|
||||
draws = iter([{"webGl:renderer": _LLVMPIPE}, {"webGl:renderer": _INTEL}])
|
||||
monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(draws))
|
||||
|
||||
assert sample_webgl_for_screen("lin", 1024, 600)["webGl:renderer"] == _INTEL
|
||||
|
||||
only_software = iter([{"webGl:renderer": _LLVMPIPE}] * 40)
|
||||
monkeypatch.setattr(fingerprints, "sample_webgl", lambda *a, **kw: next(only_software))
|
||||
assert sample_webgl_for_screen("lin", 1920, 1080)["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)
|
||||
for seed in range(25):
|
||||
fp = sample_webgl_for_screen(target_os, 1280, 800, seed=seed)
|
||||
assert gpu_screen_is_plausible(fp.get("webGl:renderer"), 1280, 800)
|
||||
|
||||
|
||||
@@ -257,8 +202,8 @@ def test_screen_floor_is_a_no_op_without_screen_values():
|
||||
@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
|
||||
build-tester drives the browser through it. It drew the GPU without the
|
||||
screen and never applied the floor, so the coherence fix reached
|
||||
launch_options() only."""
|
||||
from camoufox.fingerprints import generate_context_fingerprint
|
||||
|
||||
|
||||
@@ -18,12 +18,6 @@ What it covers:
|
||||
invented value ("what core count does a 2-core Apple M1 really have?")
|
||||
into a file whose entire purpose is being real.
|
||||
|
||||
webgl/webgl_data.db
|
||||
Each (vendor, renderer) pair carries a probability per OS. A pair the OS
|
||||
cannot report has that probability zeroed, which keeps the row for the
|
||||
platforms where it IS real -- "Radeon R9 200 Series" is a genuine Linux
|
||||
and Windows GPU, it simply never shipped in a Mac.
|
||||
|
||||
Usage:
|
||||
python3 scripts/clean-fingerprint-data.py # report only
|
||||
python3 scripts/clean-fingerprint-data.py --write # rewrite the files
|
||||
@@ -35,7 +29,6 @@ reintroduces a bad row fails CI rather than shipping.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
@@ -44,13 +37,13 @@ REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO / 'pythonlib'))
|
||||
|
||||
from camoufox import coherence # noqa: E402
|
||||
from camoufox.fingerprints import firefox_gpus, from_preset # noqa: E402
|
||||
from camoufox.fingerprints import from_preset # noqa: E402
|
||||
from camoufox.webgl import firefox_gpus # noqa: E402
|
||||
|
||||
PRESET_FILES = (
|
||||
REPO / 'pythonlib' / 'camoufox' / 'fingerprint-presets.json',
|
||||
REPO / 'pythonlib' / 'camoufox' / 'fingerprint-presets-v150.json',
|
||||
)
|
||||
WEBGL_DB = REPO / 'pythonlib' / 'camoufox' / 'webgl' / 'webgl_data.db'
|
||||
OS_KEY = {'macos': 'mac', 'windows': 'win', 'linux': 'lin'}
|
||||
# The Firefox version only decides the UA rewrite, which no rule reads.
|
||||
FF_VERSION = '152'
|
||||
@@ -104,30 +97,6 @@ def clean_presets(path, write):
|
||||
return dropped_total
|
||||
|
||||
|
||||
def clean_webgl_db(write):
|
||||
connection = sqlite3.connect(WEBGL_DB)
|
||||
cursor = connection.cursor()
|
||||
cursor.execute('SELECT rowid, vendor, renderer, win, mac, lin FROM webgl_fingerprints')
|
||||
rows = cursor.fetchall()
|
||||
zeroed = 0
|
||||
for rowid, vendor, renderer, *weights in rows:
|
||||
for column, weight in zip(('win', 'mac', 'lin'), weights):
|
||||
if weight and weight > 0 and not coherence.gpu_fits_os(renderer, column):
|
||||
zeroed += 1
|
||||
print(f' zero webgl_data.db {column}={weight:.3f} for {renderer[:58]!r}')
|
||||
if write:
|
||||
cursor.execute(
|
||||
f'UPDATE webgl_fingerprints SET {column} = 0 WHERE rowid = ?', # nosec
|
||||
(rowid,),
|
||||
)
|
||||
if write and zeroed:
|
||||
connection.commit()
|
||||
connection.close()
|
||||
print(f'webgl_data.db: {zeroed} impossible OS weight(s)'
|
||||
+ (' zeroed' if write and zeroed else ''))
|
||||
return zeroed
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--write', action='store_true', help='rewrite the data files')
|
||||
@@ -135,7 +104,6 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
total = sum(clean_presets(path, args.write) for path in PRESET_FILES)
|
||||
total += clean_webgl_db(args.write)
|
||||
|
||||
if not total:
|
||||
print('\nEvery shipped identity is coherent.')
|
||||
|
||||
Reference in New Issue
Block a user