Files
framebridge/source/tests/test_linux_compatibility.py
T

230 lines
13 KiB
Python

"""Candidate profile/ABI selection; tests use inert native-file fixtures."""
import hashlib
import json
import os
from pathlib import Path
import tempfile
import stat
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from framebridge_expanded import compatibility as compat
from framebridge_expanded import runtime, uninstall
from framebridge_expanded import display_context, network
class LinuxCompatibilityTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.prefix = Path(self.temp.name)
def artifact(self, profile_id):
_distro, _release, minor, abi = compat.PROFILES[profile_id]
vendor = self.prefix / compat.VENDOR_RELATIVE
vendor.mkdir(parents=True, exist_ok=True)
for old in vendor.rglob("*.so"):
old.unlink()
# These inert files test selection only; package tests inspect real ELF.
suffix = ".cpython-" + abi[2:] + "-x86_64-linux-gnu.so"
for rel in ["_cffi_backend", "pydantic_core/_pydantic_core", "rpds/rpds"]:
path = vendor / (rel + suffix)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"synthetic ABI filename fixture, never imported")
manifest = dict(schema=1, profile_id=profile_id, architecture="amd64",
python_minor=minor, python_abi=abi, validation_status="candidate")
(self.prefix / "release-profile.json").write_text(json.dumps(manifest))
return manifest
def check(self, distro, version, python, **options):
return compat.assess(self.prefix, os_release={"ID": distro, "VERSION_ID": version},
machine=options.pop("machine", "x86_64"), python_version=python,
libc=options.pop("libc", ("glibc", "2.39")), **options)
def test_explicit_candidate_profiles_require_matching_artifacts(self):
targets = [("debian13-amd64-py313", "debian", "13", (3, 13)),
("ubuntu24.04-amd64-py312", "ubuntu", "24.04", (3, 12)),
("ubuntu26.04-amd64-py314", "ubuntu", "26.04", (3, 14)),
("mint22-amd64-py312", "linuxmint", "22.3", (3, 12)),
("lmde7-amd64-py313", "linuxmint", "7", (3, 13))]
for name, distro, version, python in targets:
with self.subTest(name=name):
self.artifact(name)
result = self.check(distro, version, python)
self.assertTrue(result["ok"], result)
self.assertEqual(name, result["profile_id"])
self.assertEqual("candidate", result["validation_status"])
self.assertTrue(result["warnings"])
def test_version_and_derivative_identity_do_not_silently_opt_in(self):
self.artifact("ubuntu24.04-amd64-py312")
for distro, version in [("ubuntu", "22.04"), ("ubuntu", "24.10"),
("linuxmint", "21.3"), ("debian", "12"), ("pop", "24.04")]:
with self.subTest(distro=distro, version=version):
self.assertFalse(self.check(distro, version, (3, 12))["ok"])
self.assertIsNone(compat.profile_for({"ID": "other", "ID_LIKE": "ubuntu", "VERSION_ID": "24.04"}))
def test_interpreter_artifact_and_cpu_must_all_match(self):
self.artifact("ubuntu24.04-amd64-py312")
self.assertFalse(self.check("ubuntu", "24.04", (3, 13))["ok"])
self.assertFalse(self.check("linuxmint", "22.1", (3, 12))["ok"])
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), machine="aarch64")["ok"])
def test_nonstandard_interpreter_abi_is_rejected(self):
self.artifact("ubuntu24.04-amd64-py312")
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), implementation="PyPy")["ok"])
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), abi_flags="t")["ok"])
def test_mixed_native_abi_and_missing_extensions_are_rejected(self):
self.artifact("ubuntu24.04-amd64-py312")
vendor = self.prefix / compat.VENDOR_RELATIVE
path = next(vendor.glob("_cffi_backend*.so"))
path.rename(path.with_name(path.name.replace("cpython-312", "cpython-313")))
result = self.check("ubuntu", "24.04", (3, 12))
self.assertFalse(result["ok"])
self.assertTrue(any("native extension does not match" in issue for issue in result["issues"]))
self.artifact("ubuntu24.04-amd64-py312")
path = next(vendor.glob("_cffi_backend*.so"))
path.rename(path.with_name(path.name.replace("cpython-312", "cpython-312t")))
self.assertFalse(self.check("ubuntu", "24.04", (3, 12))["ok"])
self.artifact("ubuntu24.04-amd64-py312")
next((vendor / "rpds").glob("*.so")).unlink()
self.assertFalse(self.check("ubuntu", "24.04", (3, 12))["ok"])
def test_libc_floor_and_libc_family_are_checked(self):
self.artifact("ubuntu24.04-amd64-py312")
for libc in [("glibc", "2.33"), ("musl", "1.2.5"), ("", "")]:
with self.subTest(libc=libc):
self.assertFalse(self.check("ubuntu", "24.04", (3, 12), libc=libc)["ok"])
self.assertTrue(self.check("ubuntu", "24.04", (3, 12), libc=("glibc", "2.34"))["ok"])
def test_invalid_or_inconsistent_manifest_fails_closed(self):
original = self.artifact("ubuntu24.04-amd64-py312")
path = self.prefix / "release-profile.json"
for value in ["{", "[]", json.dumps({**original, "schema": True}),
json.dumps({**original, "python_abi": "cp313"}),
json.dumps({**original, "validation_status": "unknown"})]:
with self.subTest(value=value):
path.write_text(value)
result = self.check("ubuntu", "24.04", (3, 12))
self.assertFalse(result["ok"])
self.assertTrue(any("Invalid release-profile" in issue for issue in result["issues"]))
def test_missing_manifest_cannot_silently_relabel_legacy_vendor(self):
self.artifact("debian13-amd64-py313")
(self.prefix / "release-profile.json").unlink()
self.assertFalse(self.check("linuxmint", "22.1", (3, 13))["ok"])
self.assertTrue(self.check("debian", "13", (3, 13))["ok"])
with patch.object(compat, "INSTALLED_PREFIX", self.prefix):
self.assertFalse(self.check("debian", "13", (3, 13))["ok"])
def test_wayland_diagnostic_never_claims_native_desktop_control(self):
result = compat.display_support({"XDG_SESSION_TYPE": "wayland", "DISPLAY": ":0"}, {"kind": "text"})
self.assertFalse(result["native_wayland_cua"])
self.assertFalse(result["physical_x11_verified"])
self.assertIn("XWayland", result["notes"][0])
virtual = compat.display_support({"WAYLAND_DISPLAY": "wayland-0"}, {"kind": "virtual-x11"})
self.assertTrue(virtual["private_virtual_x11"])
self.assertFalse(virtual["native_wayland_cua"])
def test_doctor_uses_profile_result_and_start_stops_before_creating_files(self):
rejected = dict(ok=False, profile_id="ubuntu24.04-amd64-py312", issues=["ABI mismatch"], warnings=[])
with patch.object(runtime, "state_path", return_value=self.prefix), \
patch.object(runtime, "load_config", return_value={"role": "node", "headless": "auto"}), \
patch.object(runtime, "environment", return_value={"XDG_SESSION_TYPE": "wayland"}), \
patch.object(runtime.shutil, "which", return_value="/fixture/command"), \
patch.object(compat, "assess", return_value=rejected), \
patch.object(display_context, "missing_graphical_commands", return_value=[]), \
patch.object(display_context, "describe", return_value={"kind": "text"}), \
patch.object(network, "inspect", return_value={}), \
patch.object(runtime, "status", return_value={"running": False}):
doctor = runtime.doctor(self.prefix)
self.assertFalse(doctor["ok"])
self.assertIn("ABI mismatch", doctor["issues"])
self.assertFalse(doctor["compatible_debian_13_amd64"])
self.assertEqual("wayland", doctor["display_support"]["session_type"])
self.assertFalse(runtime.start(self.prefix)["ok"])
self.assertFalse((self.prefix / "logs").exists())
class ManagedInterpreterValidationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.state = "/home/fixture/profile"
self.unit = self.root / ("framebridge-autostart-" + hashlib.sha256(self.state.encode()).hexdigest()[:16] + ".service")
def unit_text(self, interpreter):
return '\n'.join(["# FrameBridge managed autostart v1", 'Environment="PYTHONPATH=' + uninstall.MODULE_ROOT + '"',
"ExecStart=" + interpreter + " -B -m framebridge_expanded.autostart _run --state " + self.state])
def test_matching_minor_system_interpreters_preserve_cleanup(self):
self.assertEqual(compat.SYSTEM_PYTHONS, uninstall.SYSTEM_PYTHONS)
for interpreter in sorted(compat.SYSTEM_PYTHONS):
with self.subTest(interpreter=interpreter):
self.unit.write_text(self.unit_text(interpreter))
self.assertTrue(uninstall.managed_unit(self.unit, os.getuid()))
def test_arbitrary_interpreters_duplicate_commands_and_links_remain_rejected(self):
for interpreter in ["/tmp/python3.12", "/usr/bin/python3.11", "/usr/local/bin/python3.14"]:
self.unit.write_text(self.unit_text(interpreter))
self.assertFalse(uninstall.managed_unit(self.unit, os.getuid()))
self.unit.write_text(self.unit_text("/usr/bin/python3.12") + "\nExecStart=/bin/true")
self.assertFalse(uninstall.managed_unit(self.unit, os.getuid()))
real = self.root / "real"
real.write_text(self.unit_text("/usr/bin/python3.12"))
self.unit.unlink()
self.unit.symlink_to(real)
self.assertFalse(uninstall.managed_unit(self.unit, os.getuid()))
class RootHelperCleanupTests(unittest.TestCase):
def test_root_purge_uses_only_fixed_helper_and_bounded_environment(self):
calls = []
def run(args, **options):
calls.append((args, options))
return SimpleNamespace(returncode=0)
with patch.object(uninstall.os, "getuid", return_value=0), \
patch.object(uninstall.os.path, "lexists", return_value=True), \
patch.object(uninstall, "_trusted_root_file", return_value=True):
uninstall.purge_credential_helpers(run=run)
self.assertEqual([str(uninstall.CREDENTIAL_HELPER), "purge-all"], calls[0][0])
self.assertEqual({"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"}, calls[0][1]["env"])
self.assertEqual(30, calls[0][1]["timeout"])
def test_nonroot_or_unsafe_helper_never_executes(self):
calls = []
with patch.object(uninstall.os, "getuid", return_value=1000):
with self.assertRaises(RuntimeError):
uninstall.purge_credential_helpers(run=lambda *a, **kw: calls.append(a))
with patch.object(uninstall.os, "getuid", return_value=0), \
patch.object(uninstall.os.path, "lexists", return_value=True), \
patch.object(uninstall, "_trusted_root_file", return_value=False):
with self.assertRaises(RuntimeError):
uninstall.purge_credential_helpers(run=lambda *a, **kw: calls.append(a))
self.assertEqual([], calls)
def test_failure_to_purge_pauses_removal(self):
with patch.object(uninstall.os, "getuid", return_value=0), \
patch.object(uninstall.os.path, "lexists", return_value=True), \
patch.object(uninstall, "_trusted_root_file", return_value=True):
with self.assertRaises(RuntimeError):
uninstall.purge_credential_helpers(run=lambda *a, **kw: SimpleNamespace(returncode=1))
def test_fixed_helper_trust_rejects_links_nonroot_and_writable_parents(self):
target = uninstall.CREDENTIAL_HELPER
for mode, owner in [(stat.S_IFLNK | 0o777, 0), (stat.S_IFREG | 0o755, 1000),
(stat.S_IFREG | 0o777, 0)]:
with self.subTest(mode=mode, owner=owner), \
patch.object(Path, "lstat", return_value=SimpleNamespace(st_mode=mode, st_uid=owner)):
self.assertFalse(uninstall._trusted_root_file(target))
def info(path):
return SimpleNamespace(st_uid=0, st_mode=(stat.S_IFREG | 0o755) if path == target else (stat.S_IFDIR | 0o775))
with patch.object(Path, "lstat", autospec=True, side_effect=info):
self.assertFalse(uninstall._trusted_root_file(target))
if __name__ == "__main__":
unittest.main()