mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 15:54:27 +02:00
fix(install): sign the Python that macOS grants local network access to (issue #3114)
macOS attributes Local Network permission to a code signature and judges a launchd-spawned process on its own, rather than letting it inherit the grant of the Terminal that started it. Homebrew ships Python unsigned on Intel, so there is no identity for the grant to attach to: every connection to a LAN address is dropped with no error the application can log and no permission prompt. The printer reads as unreachable and nothing says why, and the entry in Privacy & Security cannot be made to work because it refers to an identity that no longer resolves. install.sh signs during a macOS install; update_macos.sh re-checks on every update, because `brew upgrade python` installs a fresh unsigned binary under a new versioned path. Both sign only what is currently unsigned. That gate is load-bearing: on arm64 the linker ad-hoc signs every binary and the identity is a hash of the file, so re-signing would rotate it and revoke a working grant on each update. A python.org build carries a real Developer ID and must not be downgraded for the same reason. The interpreter and the framework's Python.app are both signed. The first is what sys._base_executable resolves to and what the reporter's TCC log names; the second is what his fix actually targeted. Which one macOS attributes could not be established from either, and signing both costs nothing. ----- fix(diagnostics): name the macOS permission that silently blocks the printer (issue #3114) The port checks reported all three ports unreachable while the subnet check passed, and port_mqtt's fix text sent the reporter after firewalls and IP addresses. On a macOS native install that pattern has a cause neither of those covers: no Local Network grant, denied with no error and no prompt. A new macos_local_network check, appended on macOS only so no permanently dimmed row appears for anyone else. It passes when the control port answered, which is proof the permission is in place and means the signature probe never runs on a healthy diagnostic. Otherwise it probes the interpreter: an unsigned one gets the repair that fixes it, a signed one gets System Settings — the arm64 case, where the identity is a hash of the binary, so a Python upgrade presents macOS with a new application and strands the old grant. Always warn, never fail, and only once port_mqtt has already failed, so this can never be why a green diagnostic turns red. A printer that is simply switched off produces the same all-ports-dead pattern, which is why the signature, not the pattern, is what earns the specific advice. An undeterminable signature is reported as the generic case rather than as unsigned: that advice rewrites a file in the user's Python installation and must not be offered on a guess.
This commit is contained in:
@@ -7,6 +7,7 @@ so a status flip is a user-facing regression — each one is asserted here.
|
||||
|
||||
import ipaddress
|
||||
import ssl
|
||||
import subprocess
|
||||
import types
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -14,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from backend.app.services.printer_diagnostic import (
|
||||
_check_ftps_tls,
|
||||
_host_source_ip,
|
||||
_interpreter_is_signed,
|
||||
_same_subnet,
|
||||
run_connection_diagnostic,
|
||||
)
|
||||
@@ -77,6 +79,7 @@ class _Env:
|
||||
*,
|
||||
ports=None,
|
||||
ftps="ok",
|
||||
platform="linux",
|
||||
runtime="Docker",
|
||||
network_mode="host",
|
||||
host_ip="192.168.1.5",
|
||||
@@ -90,6 +93,11 @@ class _Env:
|
||||
self.ports = ports or _port_probe()
|
||||
# What the FTPS probe reports: "ok", "closed" or "no_tls" (#2780).
|
||||
self.ftps = ftps
|
||||
# Pinned so the check list does not depend on the OS the suite runs
|
||||
# on: the macos_local_network check is emitted on darwin only (#3114),
|
||||
# and a test asserting the full set would otherwise pass on Linux CI
|
||||
# and fail on a maintainer's Mac.
|
||||
self.platform = platform
|
||||
# Container engine detect_container_runtime() reports, None for bare metal.
|
||||
self.runtime = runtime
|
||||
self.network_mode = network_mode
|
||||
@@ -130,6 +138,7 @@ class _Env:
|
||||
client.report_messages_since_connect = self.report_messages_since_connect
|
||||
client.last_connect_error = self.connect_error
|
||||
manager.get_client.return_value = client
|
||||
self._stack.enter_context(patch(f"{MOD}.sys.platform", self.platform))
|
||||
self._stack.enter_context(patch(f"{MOD}._check_port", new_callable=AsyncMock, side_effect=self.ports))
|
||||
self._stack.enter_context(patch(f"{MOD}._check_ftps_tls", new_callable=AsyncMock, return_value=self.ftps))
|
||||
self._stack.enter_context(patch(f"{MOD}.detect_container_runtime", return_value=self.runtime))
|
||||
@@ -850,3 +859,111 @@ class TestFtpsTlsProbe:
|
||||
assert capped.minimum_version == ssl.TLSVersion.TLSv1_2
|
||||
assert uncapped.maximum_version != ssl.TLSVersion.TLSv1_2
|
||||
assert uncapped.minimum_version == ssl.TLSVersion.TLSv1_2
|
||||
|
||||
|
||||
def _signature_probe(signed):
|
||||
"""Patch ``_interpreter_is_signed`` to answer ``signed``.
|
||||
|
||||
The platform itself is pinned by ``_Env(platform="darwin")``, so that one
|
||||
patch cannot be undone by the environment entered after it.
|
||||
"""
|
||||
probe = MagicMock(return_value=signed)
|
||||
return patch(f"{MOD}._interpreter_is_signed", probe), probe
|
||||
|
||||
|
||||
class TestMacosLocalNetworkCheck:
|
||||
"""The macOS Local Network (TCC) check (#3114).
|
||||
|
||||
macOS attributes the permission to a code signature. An unsigned
|
||||
interpreter has no identity to anchor a grant to, so every connection to
|
||||
the printer is dropped with no error and no prompt — the ports read as
|
||||
unreachable and nothing says why.
|
||||
"""
|
||||
|
||||
async def test_absent_on_other_platforms(self):
|
||||
"""No dimmed "skipped" row for the users who are not on a Mac."""
|
||||
with _Env(platform="linux", state=_state()):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
assert "macos_local_network" not in _statuses(result)
|
||||
|
||||
async def test_passes_when_the_control_port_answers(self):
|
||||
"""A reachable printer is proof the permission is in place."""
|
||||
patcher, probe = _signature_probe(False)
|
||||
with patcher, _Env(platform="darwin", state=_state()):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
assert _statuses(result)["macos_local_network"] == "pass"
|
||||
# And the subprocess never runs on a healthy diagnostic.
|
||||
probe.assert_not_called()
|
||||
|
||||
async def test_unsigned_interpreter_names_the_repair(self):
|
||||
patcher, _probe = _signature_probe(False)
|
||||
with patcher, _Env(platform="darwin", ports=_port_probe({8883: False}), state=_state()):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
check = _check(result, "macos_local_network")
|
||||
assert check.status == "warn"
|
||||
assert check.params["reason"] == "unsigned"
|
||||
# The path is carried so the user can see which interpreter is meant.
|
||||
assert check.params["executable"]
|
||||
|
||||
async def test_signed_interpreter_points_at_system_settings(self):
|
||||
"""arm64 always has an ad-hoc signature, so this is the common case.
|
||||
|
||||
Its identity is a hash of the binary, so a Python upgrade presents
|
||||
macOS with a new application and strands the old grant. That is
|
||||
repairable in System Settings, unlike an unsigned interpreter.
|
||||
"""
|
||||
patcher, _probe = _signature_probe(True)
|
||||
with patcher, _Env(platform="darwin", ports=_port_probe({8883: False}), state=_state()):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
check = _check(result, "macos_local_network")
|
||||
assert check.status == "warn"
|
||||
assert check.params["reason"] == "permission"
|
||||
|
||||
async def test_undeterminable_signature_is_not_reported_as_unsigned(self):
|
||||
"""No codesign, no answer — and the specific advice is withheld.
|
||||
|
||||
It names a repair that rewrites a file in the user's Python install,
|
||||
which must not be offered on a guess.
|
||||
"""
|
||||
patcher, _probe = _signature_probe(None)
|
||||
with patcher, _Env(platform="darwin", ports=_port_probe({8883: False}), state=_state()):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
assert _check(result, "macos_local_network").params["reason"] == "permission"
|
||||
|
||||
async def test_never_turns_a_healthy_result_red(self):
|
||||
"""Only ever warn, and only when the port check already failed.
|
||||
|
||||
So this check cannot be the reason a diagnostic stops being green.
|
||||
"""
|
||||
patcher, _probe = _signature_probe(False)
|
||||
with patcher, _Env(platform="darwin", state=_state(), report_messages_since_connect=42):
|
||||
result = await run_connection_diagnostic("192.168.1.50", printer=_printer())
|
||||
assert result.overall == "ok"
|
||||
|
||||
|
||||
class TestInterpreterSignatureProbe:
|
||||
"""``codesign`` has three outcomes and they must stay distinguishable."""
|
||||
|
||||
def _run(self, **kwargs):
|
||||
return patch(f"{MOD}.subprocess.run", **kwargs)
|
||||
|
||||
def test_zero_exit_means_signed(self):
|
||||
with self._run(return_value=types.SimpleNamespace(returncode=0, stderr="")):
|
||||
assert _interpreter_is_signed() is True
|
||||
|
||||
def test_not_signed_at_all_means_unsigned(self):
|
||||
stderr = "/usr/local/.../python3.14: code object is not signed at all"
|
||||
with self._run(return_value=types.SimpleNamespace(returncode=1, stderr=stderr)):
|
||||
assert _interpreter_is_signed() is False
|
||||
|
||||
def test_other_failure_is_undeterminable(self):
|
||||
"""A bad path or a codesign that would not run is not evidence."""
|
||||
with self._run(return_value=types.SimpleNamespace(returncode=1, stderr="No such file or directory")):
|
||||
assert _interpreter_is_signed() is None
|
||||
|
||||
def test_probe_failure_never_raises(self):
|
||||
"""A diagnostic that 500s the page is worse than one that says nothing."""
|
||||
with self._run(side_effect=OSError("boom")):
|
||||
assert _interpreter_is_signed() is None
|
||||
with self._run(side_effect=subprocess.TimeoutExpired(cmd="codesign", timeout=5.0)):
|
||||
assert _interpreter_is_signed() is None
|
||||
|
||||
Reference in New Issue
Block a user