fix: stop generating a canvas seed, and drop config keys nothing reads

The browser has not noised the canvas since #528, and no patch reads
canvas:seed (#721). The launcher still drew one on every launch and sent it
through CAMOU_CONFIG, and NewContext called a setCanvasSeed that does not
exist. They no longer do.

For users this changes nothing on any browser since #528: the value was
ignored. A config that still passes canvas:seed gets the usual "Skipping
unknown patch" notice instead of silence. On a browser from before #528, the
launcher no longer turns canvas noise on, which is the behaviour #528 chose.

The same audit found more keys declared in settings/properties.json that no
patch or Juggler file reads, so setting them did nothing:
- canvas:aaOffset, canvas:aaCapOffset
- memorysaver, pdfViewerEnabled, webrtc:localipv4/6
- navigator.onLine, navigator.cookieEnabled, navigator.languages
- navigator.appCodeName, appName, product, productSub. Firefox reports these
  constants itself, so fpgen.yml no longer maps them.
- webGl:parameters:blockIfNotDefined and its WebGL2 twin

test_config_schema now checks this direction too: every declared key must be
read by the browser, unless it is listed with a reason. Three are listed:
locale:script and navigator.doNotTrack, which the launcher applies itself,
and navigator.buildID (#780).

The build-tester grading followed the same wrong premise. It tracked canvas
collisions as an unfixed per-context leak. A canvas that is rendered rather
than noised follows the fonts and GPU, as it does on real machines, so canvas
collisions are now counted with the other device-level values. The tribal rule
that recorded it as an open question is now a settled one,
canvas-is-not-noised, with an automated check.

Closes #721.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jake Writer
2026-09-25 15:22:28 -06:00
co-authored by Claude Opus 5.5
parent 6a22248c0b
commit a3dbe40d1a
17 changed files with 116 additions and 129 deletions
+40
View File
@@ -38,6 +38,18 @@ MASKCONFIG_READ = re.compile(r'MaskConfig::(?:Get|Has)\w*\(\s*"([^"]+)"')
# Add here (with a reason) only when the read genuinely cannot name its key.
ALLOWED_UNDECLARED: set = set()
# Declared keys the browser never reads, each with the reason it is declared
# anyway. Everything else in properties.json must be read by a patch or by
# Juggler: a key nothing reads does nothing, silently.
NOT_READ_BY_THE_BROWSER = {
"locale:script": "the launcher joins it with locale:language/region into the UI locale",
"navigator.doNotTrack": "the launcher applies it as privacy.donottrackheader.enabled (#760)",
"navigator.buildID": "declared ahead of the patch that reads it (#780)",
}
# How Juggler and the patches name a key: a quoted string literal.
QUOTED = '"{key}"', "'{key}'"
def _sources():
for pattern in ("patches/**/*.patch", "additions/**/*"):
@@ -97,6 +109,34 @@ def test_every_key_the_browser_reads_is_declared():
pytest.fail("\n".join(lines))
def test_every_declared_key_is_read_by_the_browser():
"""The other direction: canvas:seed (#721) was declared, generated by both
launchers and sent on every launch for three releases after the patch that
read it was removed (#528)."""
text = "".join(path.read_text(errors="ignore") for path in _sources())
unread = sorted(
key
for key in _declared_keys()
if key not in NOT_READ_BY_THE_BROWSER
and not any(form.format(key=key) in text for form in QUOTED)
)
assert not unread, (
"settings/properties.json declares keys that no patch or Juggler file "
f"reads, so setting them does nothing: {unread}. Remove them, or add them "
"to NOT_READ_BY_THE_BROWSER with the reason they are declared."
)
def test_keys_not_read_by_the_browser_are_really_unread():
"""An exemption must lapse once the browser starts reading the key."""
text = "".join(path.read_text(errors="ignore") for path in _sources())
now_read = [
key for key in NOT_READ_BY_THE_BROWSER
if any(form.format(key=key) in text for form in QUOTED)
]
assert not now_read, f"remove from NOT_READ_BY_THE_BROWSER, the browser reads them now: {now_read}"
@pytest.mark.parametrize("key", ["media:spoof_codecs"])
def test_known_previously_missing_keys_stay_declared(key):
"""Pin the specific keys this guard was written for, so a schema edit that
+14 -6
View File
@@ -42,7 +42,7 @@ def launch(**kwargs):
return config_of(utils.launch_options(**kwargs))
DRAWN = ("canvas:seed", "audio:seed", "fonts", "voices", "webGl:renderer")
DRAWN = ("audio:seed", "fonts", "voices", "webGl:renderer")
def drawn(config):
@@ -51,7 +51,7 @@ def drawn(config):
class TestUnpinnedLaunchesAreDistinct:
def test_noise_seeds_do_not_collide(self):
seeds = [launch()["canvas:seed"] for _ in range(40)]
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)
@@ -77,7 +77,7 @@ class TestPinnedIdentityIsStable:
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["audio:seed"] == second["audio:seed"]
assert first["fonts"] == second["fonts"]
@pytest.mark.parametrize("os_name", ["windows", "macos", "linux"])
@@ -101,9 +101,17 @@ class TestPinnedIdentityIsStable:
assert config.get("webGl:parameters"), (os_name, i)
sample_webgl(key, config["webGl:vendor"], config["webGl:renderer"])
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_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")