mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-07 00:00:33 +00:00
fix(pythonlib): per-identity salt for seeded draws; core pinning under concurrency
Found in review of the previous commits: - identity_seed() hashed 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. The seed now mixes in identity_salt(): derived from what the caller pinned the identity with (a Fingerprint, a preset dict, a config naming the UA) so relaunching that identity reproduces every draw, and random otherwise. A pinned preset now reproduces its noise seeds too; seeds the caller sets are kept. - Concurrent AsyncNewBrowser launches on one driver interleaved pin/restore: one browser inherited the other's mask and the driver could stay pinned. pin -> launch -> restore is serialized per driver. - Every pinned browser landed on cores 0..N-1; pins now take N adjacent cores from a random start. - A pinnable host with 1-3 cores reported 1, 2 or 3 (2 is the resistFingerprinting value); the table floor of 4 applies as on other hosts. - launch_options() callers that launch the browser themselves (launch_server, direct use) kept the drawn core count although nothing pins the browser; only Camoufox/AsyncCamoufox pass pin_cpu_cores=True now, everyone else reports the host's snapped count. - PLAUSIBLE_CORE_COUNTS gains 18, 22, 28 and 32, all recorded in the -v150 corpus. - The Windows voice list was drawn before the locale was resolved, so an fr-FR identity got en-US voices; it is drawn after locale/geoip now. - macOS "Alex" gets its com.apple.speech.synthesis.voice identifier. - CAMOU_PREFS env chunks are ASCII-only JSON (Windows getenv goes through the ANSI code page). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3f0f850bf3
commit
072ad02797
@@ -321,9 +321,9 @@ class TestFixHardwareConcurrency:
|
||||
|
||||
def test_snaps_host_parallelism_when_it_cannot_pin(self, monkeypatch):
|
||||
# The host count, snapped DOWN into the
|
||||
# counts real machines ship with; the tails report 24 / 4. Used when the
|
||||
# counts real machines ship with; the tails report 32 / 4. Used when the
|
||||
# draw exceeds the host or the host cannot pin (macOS).
|
||||
# 24 is in the table (recorded on real Windows/Linux devices); 2 is NOT,
|
||||
# 18/22/24/28/32 are in the table (recorded on real devices); 2 is NOT,
|
||||
# although it is recorded, because 2 is the resistFingerprinting value.
|
||||
from camoufox import cpu_affinity, fingerprints as fp
|
||||
|
||||
@@ -332,8 +332,10 @@ class TestFixHardwareConcurrency:
|
||||
(16, 16),
|
||||
(10, 10),
|
||||
(24, 24),
|
||||
(32, 24),
|
||||
(64, 24),
|
||||
(26, 24),
|
||||
(32, 32),
|
||||
(64, 32),
|
||||
(22, 22),
|
||||
(7, 6),
|
||||
(5, 4),
|
||||
(2, 4),
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""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 = ("canvas:seed", "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()["canvas: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["canvas:seed"], first["audio:seed"]) == (second["canvas:seed"], second["audio:seed"])
|
||||
assert first["fonts"] == second["fonts"]
|
||||
|
||||
def test_caller_seeds_are_kept(self):
|
||||
config = launch(config={"canvas:seed": 7, "audio:seed": 9})
|
||||
assert (config["canvas:seed"], config["audio:seed"]) == (7, 9)
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user