Add Debian, Ubuntu and Mint candidate profiles with scoped systemd 255 credentials
This commit is contained in:
1 parent
b23819fff1
commit
a48a20c936
28 files changed
+2527
-274
No files matched your search
@@ -120,9 +120,13 @@ class AdaptiveCoreTests(unittest.TestCase):
|
||||
|
||||
def tearDown(self): self.temp.cleanup()
|
||||
|
||||
def doctor(self,missing):
|
||||
def doctor(self,missing,*,compatibility=None):
|
||||
def which(name,path=None): return None if name in missing else '/fixture/bin/'+name
|
||||
with patch.object(runtime,'environment',return_value=dict(self.env)),patch.object(runtime.shutil,'which',side_effect=which),patch('framebridge_expanded.network.inspect',return_value={}):
|
||||
# Graphical-capability tests use an eligible artifact fixture. Source
|
||||
# checkouts do not contain the installed vendor layout or manifest.
|
||||
eligibility = compatibility if compatibility is not None else {
|
||||
'ok': True, 'profile_id': 'debian13-amd64-py313', 'issues': [], 'warnings': []}
|
||||
with patch.object(runtime,'environment',return_value=dict(self.env)),patch.object(runtime.shutil,'which',side_effect=which),patch('framebridge_expanded.network.inspect',return_value={}),patch('framebridge_expanded.compatibility.assess',return_value=eligibility):
|
||||
return runtime.doctor(self.state)
|
||||
|
||||
def test_optional_gui_dependencies_do_not_block_text_tools(self):
|
||||
@@ -135,6 +139,14 @@ class AdaptiveCoreTests(unittest.TestCase):
|
||||
self.assertFalse(result['capabilities']['graphical_cua'])
|
||||
self.assertTrue(result['optional_missing_commands']['graphical_cua'])
|
||||
|
||||
def test_text_mode_does_not_bypass_incompatible_artifact(self):
|
||||
rejected = {'ok': False, 'profile_id': None, 'issues': ['Fixture artifact ABI mismatch'], 'warnings': []}
|
||||
result = self.doctor(set(dc.GRAPHICAL_COMMANDS + dc.VIRTUAL_COMMANDS), compatibility=rejected)
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('Fixture artifact ABI mismatch', result['issues'])
|
||||
self.assertEqual([], result['missing_commands'])
|
||||
self.assertFalse(result['capabilities']['graphical_cua'])
|
||||
|
||||
def test_missing_core_command_still_blocks_startup(self):
|
||||
result=self.doctor({'bash'})
|
||||
self.assertFalse(result['ok']);self.assertIn('bash',result['missing_commands'])
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Candidate archive input selection; no host package or runtime changes."""
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packaging"))
|
||||
import archive_sources
|
||||
|
||||
|
||||
class CandidateArchiveTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.base = self.root / "source"
|
||||
self.base.mkdir()
|
||||
self.input = self.base / "example.py"
|
||||
self.input.write_text("# candidate source\n")
|
||||
|
||||
def run_archive(self, args):
|
||||
evidence = {"vendor_sha256": "a" * 64, "packages": [], "native_extensions": []}
|
||||
with mock.patch.object(archive_sources, "BASE", self.base), \
|
||||
mock.patch.object(archive_sources, "source_files", return_value={"source/example.py": self.input}), \
|
||||
mock.patch.object(archive_sources, "vendor_versions", return_value={}), \
|
||||
mock.patch.object(archive_sources, "validate_vendor", return_value=evidence), \
|
||||
contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()) as stderr:
|
||||
archive_sources.main(args)
|
||||
return self.base / "release/candidate-source" / archive_sources.ARCHIVE_NAME, stderr.getvalue()
|
||||
|
||||
def test_default_archive_declares_missing_alternate_abis(self):
|
||||
output, warning = self.run_archive([])
|
||||
self.assertIn("cp312", warning)
|
||||
with tarfile.open(output) as archive:
|
||||
data = json.load(archive.extractfile(archive_sources.ARCHIVE_ROOT + "/TARGET-VENDOR-PROVENANCE.json"))
|
||||
readme = archive.extractfile(archive_sources.ARCHIVE_ROOT + "/README-SOURCE-ARCHIVE.md").read().decode()
|
||||
self.assertEqual(data["omitted_vendor_abis"], ["cp312", "cp314"])
|
||||
self.assertIn("--target debian13-amd64-py313", readme)
|
||||
self.assertIn("not a signed public release", readme)
|
||||
self.assertEqual(data["validation_status"], "candidate")
|
||||
|
||||
def test_explicit_vendors_included_without_private_markers_or_bin(self):
|
||||
args = []
|
||||
for abi in ("cp312", "cp314"):
|
||||
vendor = self.root / abi; vendor.mkdir()
|
||||
(vendor / "module.py").write_text("# reviewed input\n")
|
||||
(vendor / "private.log").write_text("private canary\n")
|
||||
(vendor / "FB-ABI-PREPARED.json").write_text('{"private_path":"/home/example"}')
|
||||
(vendor / "bin").mkdir(); (vendor / "bin/tool").write_text("#!/tmp/private/python\n")
|
||||
args.extend(["--vendor-" + abi, str(vendor)])
|
||||
output, warning = self.run_archive(args)
|
||||
self.assertFalse(warning)
|
||||
with tarfile.open(output) as archive:
|
||||
names = archive.getnames()
|
||||
data = json.load(archive.extractfile(archive_sources.ARCHIVE_ROOT + "/TARGET-VENDOR-PROVENANCE.json"))
|
||||
self.assertIn(archive_sources.ARCHIVE_ROOT + "/source/target-vendors/cp312/module.py", names)
|
||||
self.assertIn(archive_sources.ARCHIVE_ROOT + "/source/target-vendors/cp314/module.py", names)
|
||||
self.assertFalse(any("FB-ABI-PREPARED" in name or name.endswith("bin/tool") or name.endswith("private.log") for name in names))
|
||||
self.assertEqual(set(data["vendors"]), {"cp312", "cp313", "cp314"})
|
||||
self.assertEqual(data["omitted_vendor_abis"], [])
|
||||
|
||||
def test_alternate_vendor_symlink_is_rejected(self):
|
||||
vendor = self.root / "cp312"; vendor.mkdir()
|
||||
(vendor / "unsafe.py").symlink_to(self.input)
|
||||
with self.assertRaisesRegex(RuntimeError, "symlink rejected"):
|
||||
self.run_archive(["--vendor-cp312", str(vendor)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Synthetic backend/lifecycle tests. No host credentials or sudo are used."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import autostart, config, connection_background
|
||||
|
||||
ID = "tunnel_0123456789abcdef0123456789abcdef"
|
||||
KEY = "SYNTHETIC-FRAMEBRIDGE-HOST-BACKEND-NOT-A-REAL-KEY"
|
||||
|
||||
|
||||
class HostCredentialAutostartTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(); self.addCleanup(self.temp.cleanup)
|
||||
self.home = Path(self.temp.name) / "home"; self.home.mkdir(mode=0o700)
|
||||
self.state = self.home / "profile"
|
||||
value = config.default_config(); value.update(tunnel_id=ID, headless="no")
|
||||
config.save_config(self.state, value)
|
||||
self.settings = {"account_label": "owner@example.test", "tunnel_id": ID,
|
||||
"control_plane_organization_id": "", "control_plane_url_path": ""}
|
||||
self.calls = []; self.system_calls = []; self.generation = 0
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
mock.patch.object(Path, "home", return_value=self.home).start()
|
||||
self.command = mock.patch.object(autostart, "_command", side_effect=self.fake_command).start()
|
||||
self.systemctl = mock.patch.object(autostart, "_systemctl", side_effect=self.fake_systemctl).start()
|
||||
self.helper = mock.patch.object(autostart, "_helper_ready").start()
|
||||
mock.patch.object(autostart, "_linger", return_value=False).start()
|
||||
mock.patch.object(autostart, "_enabled_units", return_value=[]).start()
|
||||
mock.patch.object(autostart.os, "geteuid", return_value=1000).start()
|
||||
mock.patch.object(autostart.resource, "setrlimit").start()
|
||||
mock.patch("builtins.input", return_value="y").start()
|
||||
self.prompt = mock.patch.object(autostart.getpass, "getpass", return_value=KEY).start()
|
||||
mock.patch.object(autostart, "status", return_value={"configured": True}).start()
|
||||
mock.patch.object(connection_background, "status", return_value={"alive": False}).start()
|
||||
mock.patch.object(connection_background, "disconnect", return_value={"ok": True, "alive": False}).start()
|
||||
mock.patch("sys.stdout", new_callable=io.StringIO).start()
|
||||
|
||||
def fake_command(self, args, **kwargs):
|
||||
self.calls.append((args, kwargs))
|
||||
output = b""
|
||||
if args[:2] == ["systemd-creds", "--version"]: output = b"systemd 255\n"
|
||||
elif "protect" in args or "encrypt" in args:
|
||||
self.generation += 1
|
||||
output = ("SYNTHETIC-HOST-CIPHER-%03d-" % self.generation).encode() * 8
|
||||
elif "decrypt" in args: output = KEY.encode()
|
||||
return subprocess.CompletedProcess(args, 0, output, b"")
|
||||
|
||||
def fake_systemctl(self, *args, **kwargs):
|
||||
self.system_calls.append(args)
|
||||
return subprocess.CompletedProcess(args, 0, b"", b"")
|
||||
|
||||
def operations(self, name):
|
||||
return [(args, options) for args, options in self.calls if name in args]
|
||||
|
||||
def test_255_setup_uses_helper_stdin_and_preserves_user_unit(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
_, directory, unit = autostart._paths(self.state)
|
||||
record = autostart._manifest(directory)
|
||||
self.assertEqual(autostart.HOST_BACKEND, record["credential_backend"])
|
||||
args, options = self.operations("protect")[0]
|
||||
self.assertEqual(["sudo", "-n", "--", str(autostart.HELPER), "protect", autostart._profile_id(self.state)], args)
|
||||
self.assertEqual(KEY.encode(), options["data"])
|
||||
self.assertFalse(self.operations("encrypt"))
|
||||
self.assertNotIn(KEY, " ".join(args))
|
||||
self.assertNotIn("User=", unit.read_text())
|
||||
self.assertNotIn("LoadCredentialEncrypted", unit.read_text())
|
||||
for path in (unit, directory / "credential.cred", directory / "installation.json", self.state / "config.json"):
|
||||
self.assertNotIn(KEY.encode(), path.read_bytes())
|
||||
|
||||
def test_helper_missing_fails_before_privileges_or_key_prompt(self):
|
||||
self.helper.side_effect = RuntimeError("Install the matching package before connecting.")
|
||||
with self.assertRaisesRegex(RuntimeError, "Install"):
|
||||
autostart.enable(self.state)
|
||||
self.prompt.assert_not_called()
|
||||
self.assertFalse(any(args[0] == "sudo" for args, _ in self.calls))
|
||||
self.assertEqual([], self.system_calls)
|
||||
|
||||
def test_node_255_startup_does_not_need_a_credential_backend(self):
|
||||
config.save_config(self.state, config.default_config(role="node"))
|
||||
self.helper.side_effect = RuntimeError("missing helper")
|
||||
autostart._enable(self.state, confirm=False, start_now=False)
|
||||
self.helper.assert_not_called(); self.prompt.assert_not_called()
|
||||
self.assertFalse(self.operations("protect"))
|
||||
self.assertFalse(any(args[:2] == ["systemd-creds", "--version"] for args, _ in self.calls))
|
||||
self.assertTrue(autostart._manifest(autostart._paths(self.state)[1])["node"])
|
||||
|
||||
def test_255_decrypt_is_owner_bound_by_helper_not_owner_supplied_cipher(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.calls.clear()
|
||||
self.assertEqual(KEY, autostart._credential(self.state))
|
||||
args, options = self.operations("decrypt")[0]
|
||||
record = autostart._manifest(autostart._paths(self.state)[1])
|
||||
self.assertEqual(["sudo", "-n", "--", str(autostart.HELPER), "decrypt", autostart._profile_id(self.state), record["credential_sha256"]], args)
|
||||
self.assertNotIn("data", options)
|
||||
self.assertFalse(any(a.startswith("--uid") for a in args))
|
||||
|
||||
def test_legacy_manifest_keeps_256_decryption_without_autodetection(self):
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
cipher = b"LEGACY-CIPHERTEXT-" * 8
|
||||
autostart._write(directory / "credential.cred", cipher)
|
||||
autostart._json(directory / "installation.json", {"schema": 1, "node": False, "credential_sha256": hashlib.sha256(cipher).hexdigest()})
|
||||
self.assertEqual(KEY, autostart._credential(self.state))
|
||||
args, options = self.operations("decrypt")[0]
|
||||
self.assertEqual(["systemd-creds", "decrypt", "--user", "--name=chatgpt-runtime-key", "-", "-"], args)
|
||||
self.assertEqual(cipher, options["data"])
|
||||
self.helper.assert_not_called()
|
||||
|
||||
def test_changed_cipher_fails_before_any_privileged_decrypt(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
(autostart._paths(self.state)[1] / "credential.cred").write_bytes(b"tampered")
|
||||
self.calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "changed outside"):
|
||||
autostart._credential(self.state)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_failed_service_setup_revokes_new_helper_grant(self):
|
||||
def fail(*args, **kwargs):
|
||||
if args[0] == "enable": raise RuntimeError("synthetic service failure")
|
||||
return self.fake_systemctl(*args, **kwargs)
|
||||
self.systemctl.side_effect = fail
|
||||
with self.assertRaises(RuntimeError):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.assertEqual(1, len(self.operations("revoke")))
|
||||
self.assertIsNone(autostart._manifest(autostart._paths(self.state)[1]))
|
||||
|
||||
def test_input_race_after_protect_revokes_before_stopping_any_service(self):
|
||||
def changed(args, **kwargs):
|
||||
result = self.fake_command(args, **kwargs)
|
||||
if "protect" in args:
|
||||
settings = config.load_config(self.state); settings["name"] = "changed-during-protect"
|
||||
config.save_config(self.state, settings)
|
||||
return result
|
||||
self.command.side_effect = changed
|
||||
with self.assertRaisesRegex(RuntimeError, "changed during"):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.assertEqual(1, len(self.operations("revoke")))
|
||||
self.assertFalse(any(args[0] == "stop" for args in self.system_calls))
|
||||
|
||||
def test_rotation_revokes_old_grant_only_after_commit(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
directory = autostart._paths(self.state)[1]
|
||||
old = autostart._manifest(directory)["credential_sha256"]
|
||||
self.calls.clear(); self.system_calls.clear()
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
current = autostart._manifest(directory)
|
||||
self.assertNotEqual(old, current["credential_sha256"])
|
||||
self.assertEqual([], current["retired_helper_credentials"])
|
||||
self.assertEqual(old, self.operations("revoke")[0][0][-1])
|
||||
|
||||
def test_retired_limit_rejects_rotation_before_protect_or_service_change(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
directory = autostart._paths(self.state)[1]
|
||||
record = autostart._manifest(directory)
|
||||
record["retired_helper_credentials"] = [format(n, "064x") for n in range(128)]
|
||||
autostart._json(directory / "installation.json", record, replace=True)
|
||||
before = (directory / "installation.json").read_bytes()
|
||||
self.calls.clear(); self.system_calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "must be cleaned"):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.assertFalse(self.operations("protect"))
|
||||
self.assertEqual([], self.system_calls)
|
||||
self.assertEqual(before, (directory / "installation.json").read_bytes())
|
||||
|
||||
def test_retirement_failure_is_durable_and_disable_revokes_both_versions(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
directory = autostart._paths(self.state)[1]
|
||||
old = autostart._manifest(directory)["credential_sha256"]
|
||||
def fail_revoke(args, **kwargs):
|
||||
if "revoke" in args: raise RuntimeError("synthetic revocation failure")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = fail_revoke
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
record = autostart._manifest(directory)
|
||||
self.assertEqual([old], record["retired_helper_credentials"])
|
||||
self.command.side_effect = self.fake_command; self.calls.clear()
|
||||
autostart.disable(self.state)
|
||||
self.assertEqual("-", self.operations("prune")[0][0][-1])
|
||||
self.assertIsNone(autostart._manifest(directory))
|
||||
|
||||
def test_failed_revoke_keeps_manifest_for_retry_after_service_stop(self):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
directory = autostart._paths(self.state)[1]
|
||||
def fail_revoke(args, **kwargs):
|
||||
if "prune" in args: raise RuntimeError("synthetic revocation failure")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = fail_revoke
|
||||
with self.assertRaises(RuntimeError): autostart.disable(self.state)
|
||||
self.assertIsNotNone(autostart._manifest(directory))
|
||||
self.assertTrue(autostart._paths(self.state)[2].exists())
|
||||
|
||||
def test_disable_recovers_protect_without_an_owner_manifest(self):
|
||||
installed = self.home / "installed-helper"
|
||||
installed.write_text("synthetic trusted helper placeholder")
|
||||
with mock.patch.object(autostart, "HELPER", installed):
|
||||
result = autostart.disable(self.state)
|
||||
self.assertTrue(result["orphan_credential_cleanup_verified"])
|
||||
self.assertEqual("-", self.operations("prune")[0][0][-1])
|
||||
self.assertTrue(any(args[:2] == ["sudo", "-v"] for args, _ in self.calls))
|
||||
|
||||
def test_disable_without_installed_helper_does_not_claim_verified_cleanup(self):
|
||||
with mock.patch.object(autostart, "HELPER", self.home / "missing-helper"):
|
||||
result = autostart.disable(self.state)
|
||||
self.assertFalse(result["orphan_credential_cleanup_verified"])
|
||||
|
||||
def test_256_setup_and_disable_reconcile_orphans_after_os_upgrade(self):
|
||||
installed = self.home / "installed-helper"
|
||||
installed.write_text("synthetic trusted helper placeholder")
|
||||
def systemd256(args, **kwargs):
|
||||
if args[:2] == ["systemd-creds", "--version"]:
|
||||
self.calls.append((args, kwargs))
|
||||
return subprocess.CompletedProcess(args, 0, b"systemd 256\n", b"")
|
||||
return self.fake_command(args, **kwargs)
|
||||
self.command.side_effect = systemd256
|
||||
with mock.patch.object(autostart, "HELPER", installed):
|
||||
autostart.configure_connection(self.state, self.settings, KEY)
|
||||
self.assertEqual(1, len(self.operations("prune")))
|
||||
self.assertTrue(self.operations("encrypt")); self.assertFalse(self.operations("protect"))
|
||||
autostart.disable(self.state)
|
||||
self.assertEqual(2, len(self.operations("prune")))
|
||||
|
||||
def test_unknown_backend_never_invokes_helper_or_decrypt(self):
|
||||
_, directory, _ = autostart._paths(self.state)
|
||||
cipher = b"CIPHERTEXT-" * 10
|
||||
autostart._write(directory / "credential.cred", cipher)
|
||||
autostart._json(directory / "installation.json", {"schema": 1, "node": False, "credential_backend": "unexpected", "credential_sha256": hashlib.sha256(cipher).hexdigest()})
|
||||
self.calls.clear()
|
||||
with self.assertRaisesRegex(RuntimeError, "Unsupported"):
|
||||
autostart._credential(self.state)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Negative build-input tests; no package installation or target imports."""
|
||||
import base64
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "packaging"))
|
||||
from build_profiles import target_profile, validate_vendor
|
||||
|
||||
|
||||
class VendorValidationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.profile = target_profile("mint22-amd64-py312")
|
||||
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so")
|
||||
self.make_package("pydantic_core", "pydantic_core/_pydantic_core.cpython-312-x86_64-linux-gnu.so")
|
||||
self.make_package("rpds_py", "rpds/rpds.cpython-312-x86_64-linux-gnu.so")
|
||||
|
||||
def make_package(self, name, relative, tag="cp312-cp312-manylinux_2_28_x86_64", abi_extra=b""):
|
||||
native = self.root / relative
|
||||
native.parent.mkdir(parents=True, exist_ok=True)
|
||||
native.write_bytes(b"\x7fELF\x02\x01" + b"\0" * 12 + (62).to_bytes(2, "little") + abi_extra)
|
||||
info = self.root / (name + "-1.0.dist-info")
|
||||
info.mkdir(exist_ok=True)
|
||||
(info / "METADATA").write_text("Name: " + name + "\nVersion: 1.0\nRequires-Python: >=3.10\n")
|
||||
(info / "WHEEL").write_text("Wheel-Version: 1.0\nRoot-Is-Purelib: false\nTag: " + tag + "\n")
|
||||
self.write_record(info, native)
|
||||
return info, native
|
||||
|
||||
def write_record(self, info, native):
|
||||
rows = []
|
||||
for path in (native, info / "METADATA", info / "WHEEL"):
|
||||
digest = base64.urlsafe_b64encode(hashlib.sha256(path.read_bytes()).digest()).decode().rstrip("=")
|
||||
rows.append(path.relative_to(self.root).as_posix() + ",sha256=" + digest + "," + str(path.stat().st_size))
|
||||
rows.append((info / "RECORD").relative_to(self.root).as_posix() + ",,")
|
||||
(info / "RECORD").write_text("\n".join(rows) + "\n")
|
||||
|
||||
def test_valid_target_and_abi3(self):
|
||||
self.make_package("cryptography", "cryptography/_rust.abi3.so", "cp311-abi3-manylinux_2_34_x86_64")
|
||||
result = validate_vendor(self.root, self.profile)
|
||||
self.assertEqual(len(result["native_extensions"]), 4)
|
||||
self.assertEqual(self.profile["validation_status"], "candidate")
|
||||
|
||||
def test_wrong_wheel_abi_rejected(self):
|
||||
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so", "cp313-cp313-manylinux_2_28_x86_64")
|
||||
with self.assertRaisesRegex(RuntimeError, "wheel ABI"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_wrong_extension_abi_rejected(self):
|
||||
old = self.root / "_cffi_backend.cpython-312-x86_64-linux-gnu.so"
|
||||
old.unlink()
|
||||
self.make_package("cffi", "_cffi_backend.cpython-313-x86_64-linux-gnu.so")
|
||||
with self.assertRaisesRegex(RuntimeError, "extension ABI"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_wrong_elf_cpu_rejected(self):
|
||||
info, path = self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so")
|
||||
data = bytearray(path.read_bytes()); data[18:20] = (183).to_bytes(2, "little")
|
||||
path.write_bytes(data); self.write_record(info, path)
|
||||
with self.assertRaisesRegex(RuntimeError, "ELF amd64"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_new_glibc_rejected(self):
|
||||
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so", abi_extra=b"GLIBC_2.35")
|
||||
with self.assertRaisesRegex(RuntimeError, "glibc 2.34"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_requires_python_rejected(self):
|
||||
info, path = self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so")
|
||||
with (info / "METADATA").open("a") as stream: stream.write("Requires-Python: >=3.13\n")
|
||||
# Replace the original field to avoid duplicate-header ambiguity.
|
||||
(info / "METADATA").write_text("Name: cffi\nVersion: 1.0\nRequires-Python: >=3.13\n")
|
||||
self.write_record(info, path)
|
||||
with self.assertRaisesRegex(RuntimeError, "Requires-Python"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_version_set_change_rejected(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "names/versions"):
|
||||
validate_vendor(self.root, self.profile, {"cffi": "9.9"})
|
||||
|
||||
def test_modified_native_record_rejected(self):
|
||||
with (self.root / "_cffi_backend.cpython-312-x86_64-linux-gnu.so").open("ab") as stream:
|
||||
stream.write(b"altered")
|
||||
with self.assertRaisesRegex(RuntimeError, "RECORD digest"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_missing_wheel_rejected(self):
|
||||
(self.root / "cffi-1.0.dist-info/WHEEL").unlink()
|
||||
with self.assertRaisesRegex(RuntimeError, "WHEEL/RECORD"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_native_record_requires_digest(self):
|
||||
record = self.root / "cffi-1.0.dist-info/RECORD"
|
||||
rows = record.read_text().splitlines()
|
||||
rows[0] = rows[0].split(",")[0] + ",,"
|
||||
record.write_text("\n".join(rows) + "\n")
|
||||
with self.assertRaisesRegex(RuntimeError, "RECORD digest missing"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
def test_pure_metadata_on_native_rejected(self):
|
||||
self.make_package("cffi", "_cffi_backend.cpython-312-x86_64-linux-gnu.so", "py3-none-any")
|
||||
with self.assertRaisesRegex(RuntimeError, "native WHEEL"):
|
||||
validate_vendor(self.root, self.profile)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,7 +7,7 @@ import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import cli
|
||||
from framebridge_expanded import cli, __version__
|
||||
from framebridge_expanded.config import default_config, save_config
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class CommunityCliContractTests(unittest.TestCase):
|
||||
value=json.loads(text)
|
||||
self.assertEqual(code,0)
|
||||
self.assertEqual(value["schema_version"],1)
|
||||
self.assertEqual(value["version"],"0.3.1")
|
||||
self.assertEqual(value["version"], __version__)
|
||||
self.assertEqual(value["result"]["desired_connection"],"disconnected")
|
||||
self.assertFalse(value["result"]["connection"]["verified"])
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Synthetic, unprivileged tests; never run sudo, visudo or systemd-creds."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from framebridge_expanded import credential_helper as helper
|
||||
|
||||
KEY = b"SYNTHETIC-FRAMEBRIDGE-TEST-NOT-A-REAL-KEY"
|
||||
PROFILE = "a" * 64
|
||||
|
||||
|
||||
class CredentialHelperTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.registry = self.root / "registry"
|
||||
self.policies = self.root / "sudoers"
|
||||
self.registry.mkdir(mode=0o700)
|
||||
self.policies.mkdir(mode=0o700)
|
||||
self.owner = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_name="testowner", pw_dir="/home/testowner")
|
||||
self.calls = []
|
||||
self.generation = 0
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
mock.patch.object(helper, "ROOT", self.registry).start()
|
||||
mock.patch.object(helper, "SUDOERS", self.policies).start()
|
||||
# Functional tests run as an ordinary user. Trust-path guards have
|
||||
# dedicated stat-driven tests below rather than requiring real root.
|
||||
mock.patch.object(helper, "_root_path", side_effect=lambda p, **kw: Path(p)).start()
|
||||
original_fstat = os.fstat
|
||||
def root_stat(fd):
|
||||
values = list(original_fstat(fd)); values[4] = 0
|
||||
return os.stat_result(values)
|
||||
mock.patch.object(helper.os, "fstat", side_effect=root_stat).start()
|
||||
self.run_patcher = mock.patch.object(helper, "_run", side_effect=self.fake_run)
|
||||
self.run = self.run_patcher.start()
|
||||
|
||||
def fake_run(self, args, data=None):
|
||||
self.calls.append((args, data))
|
||||
if args[1] == "encrypt":
|
||||
self.generation += 1
|
||||
return ("SYNTHETIC-CIPHERTEXT-%03d-" % self.generation).encode() * 6
|
||||
if args[1] == "decrypt":
|
||||
return KEY
|
||||
return b""
|
||||
|
||||
def protect(self):
|
||||
cipher = helper.protect(self.owner, PROFILE, KEY)
|
||||
return cipher, hashlib.sha256(cipher).hexdigest()
|
||||
|
||||
def test_protect_has_literal_owner_rule_and_no_plaintext_in_files_or_argv(self):
|
||||
cipher, digest = self.protect()
|
||||
record, policy = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
self.assertEqual(0o600, stat.S_IMODE(record.stat().st_mode))
|
||||
self.assertEqual(0o440, stat.S_IMODE(policy.stat().st_mode))
|
||||
text = policy.read_text()
|
||||
self.assertIn("testowner ALL=(root) NOSETENV: NOPASSWD:", text)
|
||||
self.assertTrue(text.endswith(" decrypt " + PROFILE + " " + digest + "\n"))
|
||||
self.assertNotIn("*", text)
|
||||
self.assertNotIn("systemd-creds", text)
|
||||
for path in (record, policy):
|
||||
self.assertNotIn(KEY, path.read_bytes())
|
||||
encryption = self.calls[0]
|
||||
self.assertEqual(helper.CREDS, encryption[0][0])
|
||||
self.assertEqual(KEY, encryption[1])
|
||||
self.assertIn("--with-key=host", encryption[0])
|
||||
self.assertFalse(any(a.startswith("--uid") or a == "--user" for a in encryption[0]))
|
||||
self.assertNotIn(KEY.decode(), " ".join(encryption[0]))
|
||||
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, digest))
|
||||
|
||||
def test_wrong_owner_or_profile_never_decrypts(self):
|
||||
_, digest = self.protect()
|
||||
self.calls.clear()
|
||||
other = SimpleNamespace(pw_uid=5678, pw_gid=5678, pw_name="another", pw_dir="/home/another")
|
||||
for owner, profile in ((other, PROFILE), (self.owner, "b" * 64)):
|
||||
with self.subTest(owner=owner.pw_uid, profile=profile):
|
||||
with self.assertRaises(Exception):
|
||||
helper.decrypt(owner, profile, digest)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_reused_uid_with_different_username_or_home_is_rejected(self):
|
||||
_, digest = self.protect()
|
||||
for field in ("pw_name", "pw_dir"):
|
||||
values = vars(self.owner).copy(); values[field] = "changed"
|
||||
with self.subTest(field=field), self.assertRaises(RuntimeError):
|
||||
helper.decrypt(SimpleNamespace(**values), PROFILE, digest)
|
||||
|
||||
def test_cipher_or_name_or_principal_tampering_is_rejected_before_decrypt(self):
|
||||
_, digest = self.protect()
|
||||
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
original = json.loads(record.read_bytes())
|
||||
for field, value in (("cipher", "AAAA"), ("name", "another-secret"), ("uid", 0), ("profile", "b" * 64)):
|
||||
changed = dict(original); changed[field] = value
|
||||
record.write_text(json.dumps(changed)); self.calls.clear()
|
||||
with self.subTest(field=field), self.assertRaises(RuntimeError):
|
||||
helper.decrypt(self.owner, PROFILE, digest)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_symlink_hardlink_and_broad_permissions_are_rejected(self):
|
||||
_, digest = self.protect()
|
||||
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
backup = record.with_suffix(".backup"); record.rename(backup)
|
||||
record.symlink_to(backup)
|
||||
with self.assertRaises(OSError): helper.decrypt(self.owner, PROFILE, digest)
|
||||
record.unlink(); os.link(backup, record)
|
||||
with self.assertRaises(RuntimeError): helper.decrypt(self.owner, PROFILE, digest)
|
||||
record.unlink(); backup.rename(record); record.chmod(0o644)
|
||||
with self.assertRaises(RuntimeError): helper.decrypt(self.owner, PROFILE, digest)
|
||||
|
||||
def test_invalid_policy_does_not_grant_or_leave_a_record(self):
|
||||
def fail_visudo(args, data=None):
|
||||
if args[0] == helper.VISUDO: raise RuntimeError("invalid policy")
|
||||
return self.fake_run(args, data)
|
||||
self.run.side_effect = fail_visudo
|
||||
with self.assertRaises(RuntimeError): self.protect()
|
||||
self.assertEqual([], list(self.registry.iterdir()))
|
||||
self.assertEqual([], list(self.policies.iterdir()))
|
||||
|
||||
def test_rotation_revoke_and_idempotent_partial_cleanup(self):
|
||||
_, first = self.protect(); _, second = self.protect()
|
||||
self.assertNotEqual(first, second)
|
||||
helper.revoke(self.owner, PROFILE, first)
|
||||
helper.revoke(self.owner, PROFILE, first)
|
||||
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, second))
|
||||
_, policy = helper._paths(self.owner.pw_uid, PROFILE, second)
|
||||
policy.unlink() # simulate interruption after authorization removal
|
||||
helper.revoke(self.owner, PROFILE, second)
|
||||
self.assertEqual([], list(self.registry.iterdir()))
|
||||
self.assertEqual([], list(self.policies.iterdir()))
|
||||
|
||||
def test_purge_prevalidates_all_policies_before_deleting_any(self):
|
||||
_, first = self.protect(); _, second = self.protect()
|
||||
record, policy = helper._paths(self.owner.pw_uid, PROFILE, second)
|
||||
policy.chmod(0o600); policy.write_text("unrelated rule\n")
|
||||
with self.assertRaises(RuntimeError): helper.purge_all()
|
||||
self.assertTrue(helper._paths(self.owner.pw_uid, PROFILE, first)[0].exists())
|
||||
self.assertTrue(record.exists())
|
||||
|
||||
def test_purge_handles_orphan_rule_after_interrupted_revoke(self):
|
||||
_, digest = self.protect()
|
||||
record, policy = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
record.unlink()
|
||||
helper.purge_all()
|
||||
self.assertFalse(policy.exists())
|
||||
|
||||
def test_prune_recovers_lost_protect_receipt_and_preserves_committed_cipher(self):
|
||||
_, current = self.protect(); _, lost_receipt = self.protect()
|
||||
helper.prune(self.owner, PROFILE, {current})
|
||||
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, current))
|
||||
self.assertFalse(helper._paths(self.owner.pw_uid, PROFILE, lost_receipt)[0].exists())
|
||||
helper.prune(self.owner, PROFILE, set())
|
||||
self.assertEqual([], list(self.registry.iterdir()))
|
||||
self.assertEqual([], list(self.policies.iterdir()))
|
||||
|
||||
def test_revoke_persists_authorization_removal_before_deleting_cipher(self):
|
||||
_, digest = self.protect()
|
||||
record, policy = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
observed = []
|
||||
with mock.patch.object(helper, "_sync", side_effect=lambda p: observed.append((p, record.exists(), policy.exists()))):
|
||||
helper.revoke(self.owner, PROFILE, digest)
|
||||
self.assertEqual([(self.policies, True, False), (self.registry, False, False)], observed)
|
||||
|
||||
def test_purge_recovers_only_recognized_root_owned_staging_files(self):
|
||||
_, digest = self.protect()
|
||||
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
temporary = record.with_name("." + record.name + "." + "d" * 24)
|
||||
temporary.write_bytes(b"incomplete-staging-json"); temporary.chmod(0o600)
|
||||
helper.purge_all()
|
||||
self.assertFalse(temporary.exists())
|
||||
self.assertEqual([], list(self.registry.iterdir()))
|
||||
|
||||
def test_staging_hardlink_after_publish_is_recovered_before_reading_record(self):
|
||||
_, digest = self.protect()
|
||||
record, _ = helper._paths(self.owner.pw_uid, PROFILE, digest)
|
||||
temporary = record.with_name("." + record.name + "." + "d" * 24)
|
||||
os.link(record, temporary)
|
||||
original_lstat = Path.lstat
|
||||
def root_lstat(path):
|
||||
values = list(original_lstat(path)); values[4] = 0
|
||||
return os.stat_result(values)
|
||||
with mock.patch.object(Path, "lstat", root_lstat):
|
||||
helper._cleanup_temporaries()
|
||||
self.assertFalse(temporary.exists())
|
||||
self.assertEqual(KEY, helper.decrypt(self.owner, PROFILE, digest))
|
||||
|
||||
def test_broken_receipt_pipe_rolls_back_published_protect_grant(self):
|
||||
stdout = mock.Mock(); stdout.buffer.write.side_effect = BrokenPipeError("synthetic closed pipe")
|
||||
with mock.patch.object(helper, "_installation"), mock.patch.object(helper, "_caller", return_value=self.owner), mock.patch.object(helper, "_prepare_root"), mock.patch.object(helper.resource, "setrlimit"), mock.patch.object(helper.os, "umask"), mock.patch("sys.stdin", SimpleNamespace(buffer=io.BytesIO(KEY))), mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr:
|
||||
self.assertEqual(1, helper.main(["protect", PROFILE]))
|
||||
self.assertNotIn(KEY.decode(), stderr.getvalue())
|
||||
self.assertEqual([".lock"], [p.name for p in self.registry.iterdir()])
|
||||
self.assertEqual([], list(self.policies.iterdir()))
|
||||
|
||||
def test_ids_reject_paths_flags_and_extra_arguments(self):
|
||||
for value in ("../secret", "--uid=0", "A" * 64, "a" * 63, PROFILE + "\n"):
|
||||
with self.subTest(value=value), self.assertRaises(RuntimeError):
|
||||
helper.protect(self.owner, value, KEY)
|
||||
for key in (b"", b"space key", b"\x00", b"a" * (helper.MAX_KEY + 1)):
|
||||
with self.subTest(size=len(key)), self.assertRaises(RuntimeError):
|
||||
helper.protect(self.owner, PROFILE, key)
|
||||
self.assertEqual([], self.calls)
|
||||
|
||||
def test_caller_is_derived_from_sudo_identity_not_a_uid_argument(self):
|
||||
with mock.patch.object(helper.pwd, "getpwuid", return_value=self.owner):
|
||||
with mock.patch.dict(os.environ, {"SUDO_UID": "1234", "SUDO_GID": "1234", "SUDO_USER": "testowner"}, clear=True):
|
||||
self.assertEqual(self.owner, helper._caller())
|
||||
for bad in ({}, {"SUDO_UID": "0"}, {"SUDO_UID": "01234"}, {"SUDO_UID": "1234", "SUDO_GID": "1234", "SUDO_USER": "another"}):
|
||||
with mock.patch.dict(os.environ, bad, clear=True), self.assertRaises(RuntimeError):
|
||||
helper._caller()
|
||||
|
||||
def test_subprocess_has_fixed_environment_and_no_shell(self):
|
||||
self.run_patcher.stop()
|
||||
with mock.patch.object(helper.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, b"cipher", b"")) as run:
|
||||
self.assertEqual(b"cipher", helper._run([helper.CREDS, "encrypt", "-", "-"], KEY))
|
||||
_, kwargs = run.call_args
|
||||
self.assertEqual(helper.ENV, kwargs["env"])
|
||||
self.assertEqual("/", kwargs["cwd"])
|
||||
self.assertTrue(kwargs["close_fds"])
|
||||
self.assertNotIn("shell", kwargs)
|
||||
self.assertNotIn(KEY.decode(), repr(kwargs["env"]))
|
||||
|
||||
def test_main_redacts_failures_and_rejects_extra_arguments(self):
|
||||
with mock.patch.object(helper, "_installation"), mock.patch.object(helper.resource, "setrlimit"), mock.patch.object(helper.os, "umask"), mock.patch("sys.stderr", new_callable=io.StringIO) as stderr, mock.patch("sys.stdout", new_callable=io.StringIO) as stdout:
|
||||
self.assertEqual(1, helper.main(["decrypt", PROFILE, "b" * 64, "--uid=0"]))
|
||||
self.assertEqual("", stdout.getvalue())
|
||||
self.assertNotIn(KEY.decode(), stderr.getvalue())
|
||||
|
||||
|
||||
class CredentialTrustGuardTests(unittest.TestCase):
|
||||
def test_root_path_rejects_each_untrusted_component(self):
|
||||
target = Path("/rootowned/helper")
|
||||
def info(mode, uid=0, links=1):
|
||||
return SimpleNamespace(st_mode=mode, st_uid=uid, st_nlink=links)
|
||||
for bad in (info(stat.S_IFDIR | 0o777), info(stat.S_IFDIR | 0o755, uid=1000), info(stat.S_IFLNK | 0o777)):
|
||||
def fake(path):
|
||||
if path == Path("/rootowned"): return bad
|
||||
return info((stat.S_IFREG | 0o755) if path == target else (stat.S_IFDIR | 0o755))
|
||||
with mock.patch.object(Path, "lstat", fake), self.assertRaises(RuntimeError):
|
||||
helper._root_path(target)
|
||||
|
||||
def test_installed_helper_requires_isolated_no_site_interpreter(self):
|
||||
with mock.patch.object(helper.os, "geteuid", return_value=0):
|
||||
for isolated, no_site in ((0, 1), (1, 0)):
|
||||
with mock.patch.object(helper.sys, "flags", SimpleNamespace(isolated=isolated, no_site=no_site)), self.assertRaises(RuntimeError):
|
||||
helper._installation()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,100 @@
|
||||
"""A stalled unprivileged stdout reader must not retain the registry lock.
|
||||
|
||||
The subprocess disables installation/caller/crypto operations and uses only its
|
||||
own temporary directory. No sudo, host files, accounts or credentials are used.
|
||||
"""
|
||||
import array
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import termios
|
||||
import time
|
||||
import unittest
|
||||
from framebridge_expanded import credential_helper
|
||||
|
||||
CHILD = r"""
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
spec = importlib.util.spec_from_file_location("pipe_canary_helper", sys.argv[1])
|
||||
helper = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(helper)
|
||||
helper.ROOT = Path(sys.argv[2])
|
||||
helper._installation = lambda: None
|
||||
helper._prepare_root = lambda: None
|
||||
helper._caller = lambda: SimpleNamespace(pw_uid=12345, pw_name="synthetic", pw_dir="/synthetic-only")
|
||||
helper.decrypt = lambda *args: b"X" * 16384
|
||||
# The only file is our own temporary lock; simulate its expected root metadata.
|
||||
actual_fstat = helper.os.fstat
|
||||
helper.os.fstat = lambda fd: SimpleNamespace(st_mode=actual_fstat(fd).st_mode,
|
||||
st_uid=0, st_nlink=actual_fstat(fd).st_nlink)
|
||||
raise SystemExit(helper.main(["decrypt", "a" * 64, "b" * 64]))
|
||||
"""
|
||||
|
||||
|
||||
class CredentialHelperPipeTests(unittest.TestCase):
|
||||
@unittest.skipUnless(hasattr(fcntl, "F_SETPIPE_SZ"), "Linux pipe-size controls required")
|
||||
def test_stalled_stdout_reader_does_not_hold_global_registry_lock(self):
|
||||
with tempfile.TemporaryDirectory(prefix="framebridge-pipe-canary-") as directory:
|
||||
read_fd, write_fd = os.pipe()
|
||||
child = None
|
||||
lock_fd = None
|
||||
try:
|
||||
capacity = fcntl.fcntl(write_fd, fcntl.F_SETPIPE_SZ, 4096)
|
||||
child = subprocess.Popen([sys.executable, "-I", "-S", "-c", CHILD,
|
||||
str(Path(credential_helper.__file__).resolve()), directory],
|
||||
stdin=subprocess.DEVNULL, stdout=write_fd,
|
||||
stderr=subprocess.PIPE, close_fds=True)
|
||||
os.close(write_fd)
|
||||
write_fd = None
|
||||
deadline = time.monotonic() + 5
|
||||
buffered = array.array("i", [0])
|
||||
while time.monotonic() < deadline:
|
||||
fcntl.ioctl(read_fd, termios.FIONREAD, buffered, True)
|
||||
if buffered[0] == capacity:
|
||||
break
|
||||
if child.poll() is not None:
|
||||
self.fail("Synthetic helper exited before filling the canary pipe: " + child.stderr.read().decode())
|
||||
time.sleep(0.01)
|
||||
self.assertEqual(capacity, buffered[0], "Synthetic pipe did not fill")
|
||||
self.assertIsNone(child.poll(), "Helper should be waiting for the synthetic stdout reader")
|
||||
lock_fd = os.open(Path(directory) / ".lock", os.O_RDWR)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
self.fail("Helper retained the registry lock while stdout was blocked")
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
received = bytearray()
|
||||
deadline = time.monotonic() + 5
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
self.assertGreater(remaining, 0, "Synthetic helper pipe did not finish")
|
||||
ready, _, _ = select.select([read_fd], [], [], remaining)
|
||||
self.assertTrue(ready, "Synthetic helper pipe timed out")
|
||||
block = os.read(read_fd, 4096)
|
||||
if not block:
|
||||
break
|
||||
received.extend(block)
|
||||
self.assertEqual(b"X" * 16384, bytes(received))
|
||||
self.assertEqual(0, child.wait(timeout=3), child.stderr.read().decode())
|
||||
finally:
|
||||
if child is not None:
|
||||
if child.poll() is None:
|
||||
child.kill()
|
||||
child.wait(timeout=3)
|
||||
child.stderr.close()
|
||||
os.close(read_fd)
|
||||
if write_fd is not None:
|
||||
os.close(write_fd)
|
||||
if lock_fd is not None:
|
||||
os.close(lock_fd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,229 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user