mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-04 08:00:19 +00:00
voices.json shipped in the wheel, but no code in the package reads it: the
voice draw uses voice-manifests.json and voice-uris.json. Its only readers
are the TypeScript port's golden-fixture generator and data-sync script
(typescript/scripts/golden/identity_golden.py,
typescript/scripts/sync-identity-data.py), which live on another branch and
will need a new source; the last copy is at
676fb3f:pythonlib/camoufox/voices.json. docs/per-context-patches.md
described it as runtime data and now describes the files that are.
webgl_data.db held two rows with zero weight on every OS ("Intel(R) HD
Graphics 400, or similar" from "Intel Inc." and "Radeon R9 200 Series, or
similar" from "ATI Technologies Inc."), left behind when their impossible
macOS weights were zeroed. No draw can reach them. They are deleted with
secure_delete so their blobs do not linger in free pages; the file is not
vacuumed, so the other pages are unchanged. A new test requires every row
to be drawable on at least one OS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
8.5 KiB
Python
204 lines
8.5 KiB
Python
"""Per-identity draws: unrelated launches must not share them, a pinned identity must.
|
|
|
|
identity_seed() used to hash only the UA, platform, screen size and core count.
|
|
Those take a handful of values per OS, so over 500 launches the seed took 12-30
|
|
distinct values, and every install drew its fonts, voices, GPU, media devices and
|
|
canvas/audio noise seeds from that same short list.
|
|
"""
|
|
|
|
from contextlib import contextmanager
|
|
from unittest import mock
|
|
|
|
import orjson
|
|
import pytest
|
|
|
|
from camoufox import cpu_affinity, utils
|
|
from camoufox import fingerprints as fp
|
|
|
|
|
|
@contextmanager
|
|
def host():
|
|
with mock.patch.object(utils, "get_screen_cons", lambda headless: None), (
|
|
mock.patch.object(utils, "has_display", lambda env: False)
|
|
), mock.patch.object(utils, "installed_verstr", lambda: "150.0.2"), (
|
|
mock.patch.object(utils, "launch_path", lambda **kwargs: "/nonexistent/camoufox")
|
|
):
|
|
yield
|
|
|
|
|
|
def config_of(options):
|
|
env = options["env"]
|
|
chunks = sorted(
|
|
(int(k.rsplit("_", 1)[1]), v) for k, v in env.items() if k.startswith("CAMOU_CONFIG_")
|
|
)
|
|
return orjson.loads("".join(chunk for _, chunk in chunks))
|
|
|
|
|
|
def launch(**kwargs):
|
|
kwargs.setdefault("os", "linux")
|
|
kwargs.setdefault("headless", True)
|
|
kwargs.setdefault("i_know_what_im_doing", True)
|
|
with host():
|
|
return config_of(utils.launch_options(**kwargs))
|
|
|
|
|
|
DRAWN = ("audio:seed", "fonts", "voices", "webGl:renderer")
|
|
|
|
|
|
def drawn(config):
|
|
return {k: orjson.dumps(config.get(k)) for k in DRAWN}
|
|
|
|
|
|
class TestUnpinnedLaunchesAreDistinct:
|
|
def test_noise_seeds_do_not_collide(self):
|
|
seeds = [launch()["audio:seed"] for _ in range(40)]
|
|
# 40 draws from 2**32: any collision means the seed space collapsed.
|
|
assert len(set(seeds)) == len(seeds)
|
|
|
|
def test_same_presented_values_still_differ(self):
|
|
# The same UA/platform/screen/cores, i.e. what two users on the same
|
|
# common machine present, must not yield the same noise seeds.
|
|
config = {"navigator.userAgent": "x", "navigator.platform": "Win32",
|
|
"screen.width": 1920, "screen.height": 1080, "navigator.hardwareConcurrency": 8}
|
|
seeds = {fp.identity_seed(config, fp.identity_salt()) for _ in range(200)}
|
|
assert len(seeds) == 200
|
|
|
|
|
|
class TestPinnedIdentityIsStable:
|
|
def test_fixed_fingerprint_reproduces_every_draw(self):
|
|
fingerprint = fp.generate_fingerprint(os="linux")
|
|
first = launch(fingerprint=fingerprint)
|
|
second = launch(fingerprint=fingerprint)
|
|
assert drawn(first) == drawn(second)
|
|
|
|
def test_fixed_preset_reproduces_noise_seeds(self):
|
|
preset = fp.get_random_preset(os="windows", ff_version="150")
|
|
if not preset:
|
|
pytest.skip("no presets bundled")
|
|
first = launch(os="windows", fingerprint_preset=preset)
|
|
second = launch(os="windows", fingerprint_preset=preset)
|
|
assert first["audio:seed"] == second["audio:seed"]
|
|
assert first["fonts"] == second["fonts"]
|
|
|
|
@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
|
|
|
|
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.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
|
|
|
|
|
|
def test_no_canvas_seed_is_generated():
|
|
"""The browser adds no canvas noise (#528), and no patch reads canvas:seed
|
|
(#721). Generating one only sent the browser a value it ignored."""
|
|
assert "canvas:seed" not in launch()
|
|
context = fp.generate_context_fingerprint(os="linux")
|
|
assert "canvas:seed" not in context["config"]
|
|
assert "setCanvasSeed" not in context["init_script"]
|
|
|
|
def test_salt_of_equal_objects_is_equal(self):
|
|
a = fp.generate_fingerprint(os="windows")
|
|
assert fp.identity_salt(a) == fp.identity_salt(a)
|
|
assert fp.identity_salt({"a": 1, "b": 2}) == fp.identity_salt({"b": 2, "a": 1})
|
|
|
|
|
|
class TestVoicesFollowLocale:
|
|
def test_windows_fr_identity_has_french_voices(self, monkeypatch):
|
|
for _ in range(5):
|
|
config = launch(os="windows", locale="fr-FR")
|
|
langs = {v["lang"] for v in config["voices"]}
|
|
assert "fr-FR" in langs, langs
|
|
|
|
|
|
class TestCoreCountFloor:
|
|
def test_small_pinnable_host_reports_table_floor(self, monkeypatch):
|
|
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
|
|
for host_cores in (1, 2, 3):
|
|
monkeypatch.setattr(fp, "host_cpu_count", lambda n=host_cores: n)
|
|
for drawn_cores in (1, 2, 3, 8):
|
|
c = {"navigator.hardwareConcurrency": drawn_cores}
|
|
fp.fix_hardware_concurrency(c)
|
|
assert c["navigator.hardwareConcurrency"] == 4, (host_cores, drawn_cores)
|
|
|
|
def test_unpinned_launch_reports_host(self, monkeypatch):
|
|
monkeypatch.setattr(cpu_affinity, "supported", lambda: True)
|
|
monkeypatch.setattr(fp, "host_cpu_count", lambda: 16)
|
|
c = {"navigator.hardwareConcurrency": 8}
|
|
fp.fix_hardware_concurrency(c, can_pin=False)
|
|
assert c["navigator.hardwareConcurrency"] == 16
|
|
|
|
def test_recorded_counts_are_in_the_table(self):
|
|
for n in (18, 22, 28, 32):
|
|
assert n in fp.PLAUSIBLE_CORE_COUNTS
|
|
|
|
|
|
class TestAffinityPick:
|
|
def test_adjacent_cores_from_a_random_start(self):
|
|
cores = list(range(16))
|
|
starts = set()
|
|
for _ in range(200):
|
|
picked = cpu_affinity._pick(cores, 4)
|
|
assert len(picked) == 4 and set(picked) <= set(cores)
|
|
ring = sorted(picked)
|
|
# adjacent modulo 16
|
|
assert any(all((s + i) % 16 in picked for i in range(4)) for s in ring)
|
|
starts.add(tuple(picked))
|
|
assert len(starts) > 4
|
|
|
|
|
|
class TestPrefsEnvIsAscii:
|
|
def test_non_ascii_pref_round_trips(self):
|
|
prefs = {"font.name.serif.ja": "游明朝", "intl.accept_languages": "fr-FR, fr"}
|
|
env = utils.get_pref_env_vars(prefs)
|
|
joined = "".join(env[f"CAMOU_PREFS_{i}"] for i in range(1, len(env) + 1))
|
|
assert joined.isascii()
|
|
assert orjson.loads(joined) == prefs
|
|
|
|
|
|
@pytest.mark.parametrize("off", [None, False])
|
|
def test_fingerprint_preset_off_never_draws_a_preset(off):
|
|
"""`fingerprint_preset=False` means off, the same as None. It used to be
|
|
checked with `is not None`, so False drew a random bundled preset."""
|
|
with mock.patch.object(utils, "get_random_preset", side_effect=AssertionError("preset drawn")):
|
|
launch(fingerprint_preset=off)
|
|
|
|
|
|
def test_no_glyph_spacing_seed_is_generated():
|
|
"""Glyph-spacing noise moved every measured text width off what the same
|
|
font gives on a real machine, so it was itself a fingerprint; the feature
|
|
is gone from the browser, and the launcher sends nothing for it."""
|
|
assert "fonts:spacing_seed" not in launch()
|
|
context = fp.generate_context_fingerprint(os="linux")
|
|
assert "fonts:spacing_seed" not in context["config"]
|
|
assert "setFontSpacingSeed" not in context["init_script"]
|
|
|
|
|
|
def test_config_overrides_reach_the_config_and_the_init_script():
|
|
context = fp.generate_context_fingerprint(os="linux", config_overrides={"audio:seed": 7})
|
|
assert context["config"]["audio:seed"] == 7
|
|
assert "setAudioFingerprintSeed(7)" in context["init_script"]
|
|
|
|
|
|
def test_instant_animations_warn_that_they_are_detectable():
|
|
from camoufox._warnings import LeakWarning
|
|
|
|
with pytest.warns(LeakWarning, match="getComputedTiming"):
|
|
launch(config={"instantAnimations": True}, i_know_what_im_doing=False)
|