mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-07 16:00:34 +00:00
Launcher-side fixes found by comparing camoufox against stock Firefox 152.0.4 on Linux, Windows 11 and macOS hosts: - DNT / GPC: BrowserForge draws doNotTrack "1" on most Firefox samples, but a stock Firefox 152 reports "unspecified" and globalPrivacyControl false; the stock defaults are used unless the caller sets them, and both are applied as prefs so the API, the worker and the DNT / Sec-GPC headers agree (#760). - Timezone and geolocation: the timezone is passed to the browser, and a configured position sets permissions.default.geo so permissions.query agrees with the auto-grant (#769, #773). - hardwareConcurrency: the reported count is the fingerprint's and the browser is pinned to that many cores (cpu_affinity.py, Linux/Windows), so worker timing agrees with it; otherwise the host count snapped into the core counts real machines ship with (never 2, Firefox's resistFingerprinting value). - Fonts: the OS base is always present in full, OS-version variants are drawn all-or-nothing, co-shipped groups stay together, Cascadia is never claimed off Windows, a native macOS/Windows identity claims only the real OS base, and gfx.font_rendering.fallback.async is off on Linux so per-character fallback does not depend on cmap-load timing. - Speech voices: a per-OS installed-voice model (voice-manifests.json) with the voiceURI formats each backend really produces (voice-uris.json); no default voice where stock has none. - WebGL: extensions a release Firefox never exposes are filtered, but OVR_multiview2 stays for Windows D3D11 renderers, which expose it. - Media devices: a seeded draw of common per-OS devices with OS-style labels. - Windows scrollbars follow the drawn Windows version (overlay on 11). - Glyph-advance perturbation (fonts:spacing_seed) defaults to off: it moved every measureText width off the value the same font gives on a real machine. - Launcher prefs are also exported as CAMOU_PREFS_1..N so camoufox.cfg applies them at startup, and the browser UI locale follows the spoofed locale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
6.6 KiB
Python
196 lines
6.6 KiB
Python
"""
|
|
Tests for camoufox.fingerprints speech-voice generation.
|
|
|
|
Mirrors camoufox-js/tests-camoufox-js/voices.test.ts.
|
|
|
|
Run with:
|
|
cd pythonlib && python -m pytest tests/test_voices.py -v
|
|
|
|
The core regression these guard: every spoofable OS -- including Linux --
|
|
must yield a non-empty list of MaskConfig voice OBJECTS (not raw
|
|
"Name:lang:type" strings), or the C++ MaskConfig::MVoices() silently drops
|
|
them and the host machine's native voices leak through.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
# Make `import camoufox` resolve to the in-tree pythonlib without an install.
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
from camoufox.fingerprints import ( # noqa: E402
|
|
_generate_random_voice_subset,
|
|
_normalize_preset_voices,
|
|
)
|
|
|
|
_REQUIRED_FIELDS = {"lang", "name", "voiceUri", "isDefault", "isLocalService"}
|
|
|
|
|
|
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
|
|
def test_non_empty_for_every_os(target_os):
|
|
voices = _generate_random_voice_subset(target_os, "en-US")
|
|
assert len(voices) > 0
|
|
|
|
|
|
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
|
|
def test_entries_are_full_objects(target_os):
|
|
# MaskConfig::MVoices() drops any entry missing a field, so every voice
|
|
# must carry the full object shape.
|
|
for v in _generate_random_voice_subset(target_os, "en-US"):
|
|
assert isinstance(v, dict)
|
|
assert _REQUIRED_FIELDS <= set(v.keys())
|
|
|
|
|
|
@pytest.mark.parametrize("target_os", ["macos", "windows", "linux"])
|
|
def test_no_default_voice(target_os):
|
|
# Stock Firefox 152 marks no SpeechSynthesisVoice.default on any OS.
|
|
voices = _generate_random_voice_subset(target_os, "en-US")
|
|
assert voices and not any(v["isDefault"] for v in voices)
|
|
|
|
|
|
def test_windows_display_language_pack():
|
|
# A German display language gets the German OneCore pack first; en-US is
|
|
# the pack every stock en-US box reports (David / Mark / Zira + Desktop).
|
|
de = _generate_random_voice_subset("windows", "de-DE", seed=1)
|
|
assert de[0]["lang"] == "de-DE"
|
|
en = _generate_random_voice_subset("windows", "en-US", seed=1)
|
|
assert [v["name"] for v in en[:3]] == [
|
|
"Microsoft David - English (United States)",
|
|
"Microsoft Mark - English (United States)",
|
|
"Microsoft Zira - English (United States)",
|
|
]
|
|
|
|
|
|
class TestLinuxSpeechdUris:
|
|
"""Linux voiceUris must match Firefox's SpeechDispatcherService.cpp:
|
|
urn:moz-tts:speechd:<NS_EscapeURL(name, OnlyNonASCII|Spaces)>?<lang>
|
|
"""
|
|
|
|
def setup_method(self):
|
|
self.lin = _generate_random_voice_subset("linux", "en-US")
|
|
|
|
def test_prefix_and_lang_suffix(self):
|
|
for v in self.lin:
|
|
assert v["voiceUri"].startswith("urn:moz-tts:speechd:")
|
|
assert v["voiceUri"].endswith("?" + v["lang"])
|
|
|
|
def test_spaces_escaped_punctuation_intact(self):
|
|
gb = next(v for v in self.lin if v["name"] == "English (Great Britain)")
|
|
assert gb["voiceUri"] == "urn:moz-tts:speechd:English%20(Great%20Britain)?en-GB"
|
|
|
|
def test_all_local_service(self):
|
|
assert all(v["isLocalService"] for v in self.lin)
|
|
|
|
|
|
def test_normalize_preset_voices_converts_strings():
|
|
# Presets historically store "Name:lang:type" strings.
|
|
out = _normalize_preset_voices(
|
|
["Albert:en-US:local", "Alice:it-IT:local"], "macos"
|
|
)
|
|
assert all(_REQUIRED_FIELDS <= set(v.keys()) for v in out)
|
|
assert out[0]["name"] == "Albert"
|
|
assert out[0]["lang"] == "en-US"
|
|
assert sum(1 for v in out if v["isDefault"]) == 1
|
|
|
|
|
|
def test_normalize_preset_voices_passes_through_objects():
|
|
obj = {
|
|
"name": "Alex",
|
|
"lang": "en-US",
|
|
"voiceUri": "urn:moz-tts:osx:alex",
|
|
"isDefault": True,
|
|
"isLocalService": True,
|
|
}
|
|
out = _normalize_preset_voices([obj], "macos")
|
|
assert out == [obj]
|
|
|
|
|
|
def test_unknown_os_falls_back_to_macos():
|
|
assert len(_generate_random_voice_subset("plan9", "en-US")) > 0
|
|
|
|
|
|
# ── Fail-closed voice configuration (daijro/camoufox#731) ────────────────────
|
|
#
|
|
# nsSynthVoiceRegistry only withholds the host's speech-dispatcher / SAPI /
|
|
# NSSpeech voices while Camoufox owns the list. An empty or unset `voices`
|
|
# used to fall through to the host backend and register every native voice --
|
|
# 14805 espeak-ng entries on a stock Linux box -- under a fingerprint claiming
|
|
# macOS or Windows.
|
|
|
|
from camoufox.exceptions import InvalidPropertyType # noqa: E402
|
|
from camoufox.utils import validate_voices # noqa: E402
|
|
|
|
|
|
def _launch_config(**kwargs):
|
|
"""Rebuild the config dict from the chunked CAMOU_CONFIG_* env vars."""
|
|
import json
|
|
|
|
from camoufox.utils import launch_options
|
|
|
|
opts = launch_options(headless=True, i_know_what_im_doing=True, **kwargs)
|
|
env = opts["env"]
|
|
chunks = sorted(
|
|
(k for k in env if k.startswith("CAMOU_CONFIG_")),
|
|
key=lambda k: int(k.rsplit("_", 1)[1]),
|
|
)
|
|
return json.loads("".join(env[k] for k in chunks))
|
|
|
|
|
|
def test_block_flag_is_pinned_by_default():
|
|
# Without this the browser has no instruction to withhold host voices when
|
|
# the list it receives turns out to be empty or unusable.
|
|
assert _launch_config(os="macos")["voices:blockIfNotDefined"] is True
|
|
|
|
|
|
def test_caller_can_override_the_block_flag():
|
|
cfg = _launch_config(os="macos", config={"voices:blockIfNotDefined": False})
|
|
assert cfg["voices:blockIfNotDefined"] is False
|
|
|
|
|
|
def test_voice_generation_failure_fails_closed(monkeypatch):
|
|
import camoufox.utils as utils
|
|
|
|
def boom(*_args, **_kwargs):
|
|
raise RuntimeError("voices.json unreadable")
|
|
|
|
monkeypatch.setattr(utils, "_generate_random_voice_subset", boom)
|
|
cfg = _launch_config(os="macos")
|
|
# An empty list plus the block flag means "no voices" -- never "all of the
|
|
# host's".
|
|
assert cfg["voices"] == []
|
|
assert cfg["voices:blockIfNotDefined"] is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
["Alex:en-US:local"], # the bare-string shape MVoices() drops
|
|
[{"name": "Alex", "lang": "en-US"}], # incomplete object
|
|
"Alex", # not a list at all
|
|
],
|
|
)
|
|
def test_validate_voices_rejects_unusable_shapes(value):
|
|
with pytest.raises(InvalidPropertyType):
|
|
validate_voices(value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
[
|
|
[], # empty is legal: it means "no voices", and the flag enforces it
|
|
[
|
|
{
|
|
"lang": "en-US",
|
|
"name": "Alex",
|
|
"voiceUri": "urn:moz-tts:osx:alex",
|
|
"isDefault": True,
|
|
"isLocalService": True,
|
|
}
|
|
],
|
|
],
|
|
)
|
|
def test_validate_voices_accepts_usable_shapes(value):
|
|
validate_voices(value)
|