swayfx-mosaic/tooling/probes/step20c-egl-fx/test_cgroup_admission.py

1123 lines
46 KiB
Python

"""Tests des fonctions de production ; aucune création native n'est permise."""
import contextlib
import copy
import io
import json
import os
from pathlib import Path
import resource
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
import run
INERT_POPEN = subprocess.Popen
gate = run.gate
UID = os.getuid()
PID = os.getpid()
BASE = f"/user.slice/user-{UID}.slice/user@{UID}.service"
APP = BASE + "/app.slice"
SPEC = gate.policy("a" * 32)
gate = run.gate.module # Patch the production module globals, not the lazy proxy.
GROUP = APP + "/" + SPEC["unit"]
def native_spawn_forbidden(*args, **kwargs):
raise AssertionError("native_spawn_forbidden")
_native_sentinel = None
def setUpModule():
global _native_sentinel
_native_sentinel = patch.object(
subprocess, "Popen", side_effect=native_spawn_forbidden)
_native_sentinel.start()
def tearDownModule():
_native_sentinel.stop()
class FakeProc:
def __init__(self):
self.identities = {
PID: {"pid": PID, "uid": UID, "startticks": 111,
"cgroup": APP + "/launcher.scope"},
42: {"pid": 42, "uid": UID, "startticks": 222,
"cgroup": BASE + "/init.scope"},
43: {"pid": 43, "uid": UID, "startticks": 333, "cgroup": GROUP},
}
self.limits = (4096, 4096)
self.load = 1126
self.writers = []
def identity(self, pid):
return dict(self.identities[pid])
def uid_threads(self, uid):
return self.load
def nproc(self):
return self.limits
def pidfd(self, pid):
reader, writer = os.pipe()
self.writers.append(writer)
return reader
def close(self):
for writer in self.writers:
os.close(writer)
class FakeCgroups:
def __init__(self):
self.mount_id = (1, "0:30")
self.controllers = (["pids"], ["pids"], True)
self.present = False
self.is_empty = True
self.record = {"inode": 5, "device": 30, "uid": UID,
"pids_max": "256", "pids_current": 1, "procs": [43],
"children": []}
self.ancestor = {**self.record, "pids_max": "4096", "pids_current": 3}
def mount(self):
return self.mount_id
def snapshot(self, group):
return dict(self.record if group == GROUP else self.ancestor)
def available(self, group):
return self.controllers
def exists(self, group):
return self.present
def empty(self, group):
return self.is_empty
class FakeProcess:
def __init__(self):
self.returncode = None
self.stdout = io.BytesIO()
self.stderr = io.BytesIO()
self.killed = False
self.wait_error = None
def poll(self):
return self.returncode
def kill(self):
self.killed = True
self.returncode = -9
def wait(self, timeout):
if self.wait_error:
raise self.wait_error
self.returncode = 0 if self.returncode is None else self.returncode
return self.returncode
class FakeManager:
def __init__(self, cgroups):
self.cgroups = cgroups
self.created = 0
self.stopped = []
self.owner = {"Id": f"user@{UID}.service", "Delegate": "yes",
"ActiveState": "active",
"ControlGroup": BASE, "InvocationID": "b" * 32, "MainPID": "42"}
self.app = {"Id": "app.slice", "ActiveState": "active", "ControlGroup": APP}
self.scope = {"Id": SPEC["unit"], "Description": SPEC["description"],
"InvocationID": "c" * 32, "ControlGroup": GROUP, **gate.POLICY}
self.process = FakeProcess()
self.create_error = None
self.stop_error = None
def show(self, unit, deadline, system=False):
if unit == f"user@{UID}.service":
return dict(self.owner)
if unit == "app.slice":
return dict(self.app)
return dict(self.scope) if self.cgroups.present else {"LoadState": "not-found"}
def create(self, spec, argv):
self.created += 1
if self.create_error:
raise self.create_error
self.cgroups.present = True
return self.process
def stop(self, unit, deadline):
if self.stop_error:
raise self.stop_error
self.stopped.append(unit)
self.cgroups.present = False
class FakeBarrier:
def __init__(self, *args):
self.released = 0
self.closed = False
self.error = None
def accept(self):
if self.error:
raise self.error
return 43
def release(self):
self.released += 1
def close(self):
self.closed = True
class UIDThreadTests(unittest.TestCase):
"""Faux procfs sur disque ; exerce le vrai compteur, sans enfant natif."""
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.proc = gate.ProcFS(self.root)
self.add_process(101)
def add_process(self, pid, uids=(1000, 1000, 1000, 1000), threads=2):
directory = self.root / str(pid)
directory.mkdir()
fields = ["S"] + ["0"] * 18 + ["123"]
(directory / "stat").write_text(f"{pid} (fixture) " + " ".join(fields))
(directory / "cgroup").write_text("0::/fixture.scope\n")
(directory / "status").write_text(
"Uid:\t" + " ".join(map(str, uids)) + f"\nThreads:\t{threads}\n")
(directory / "task").mkdir()
for tid in range(pid, pid + threads):
(directory / "task" / str(tid)).mkdir()
return directory
def change_before_second_sample(self, action):
"""Injecte une mutation entre les deux lectures réelles du compteur."""
original = Path.read_text
reads = 0
def read(path, *args, **kwargs):
nonlocal reads
if path == self.root / "101" / "status":
reads += 1
if reads == 2:
action()
return original(path, *args, **kwargs)
return patch.object(Path, "read_text", read)
def test_homogeneous_uid_count(self):
self.assertEqual(self.proc.uid_threads(1000), 2)
def test_mixed_uid_count_does_not_call_identity(self):
status = self.root / "101" / "status"
status.write_text("Uid: 1000 0 0 0\nThreads: 2\n")
original = Path.stat
def root_owned(path, *args, **kwargs):
info = original(path, *args, **kwargs)
if path == self.root / "101":
values = list(info)
values[4] = 0 # A setuid helper's proc directory may be root-owned.
return os.stat_result(values)
return info
with patch.object(Path, "stat", root_owned):
with patch.object(self.proc, "identity", side_effect=AssertionError):
self.assertEqual(self.proc.uid_threads(1000), 2)
def test_multiple_processes_and_foreign_real_uid(self):
self.add_process(201, (1000, 0, 0, 0), 3)
self.add_process(301, (2000, 1000, 1000, 1000), 4)
self.assertEqual(self.proc.uid_threads(1000), 5)
def test_identity_still_refuses_mixed_credentials_for_owned_roles(self):
(self.root / "101" / "status").write_text(
"Uid: 1000 0 0 0\nThreads: 2\n")
for role in ("supervisor", "bootstrap", "manager"):
with self.subTest(role=role):
with self.assertRaisesRegex(gate.Refusal, "UID process divergent"):
self.proc.identity(101)
def test_uid_startticks_cgroup_and_threads_drifts_refuse(self):
directory = self.root / "101"
changes = {
"uid": ("status", "Uid: 1000 0 0 0\nThreads: 2\n"),
"real_uid": ("status", "Uid: 2000 0 0 0\nThreads: 2\n"),
"threads": ("status", "Uid: 1000 1000 1000 1000\nThreads: 1\n"),
"startticks": ("stat", "101 (fixture) " +
" ".join(["S"] + ["0"] * 18 + ["456"])),
"pid": ("stat", "999 (fixture) " +
" ".join(["S"] + ["0"] * 18 + ["123"])),
"cgroup": ("cgroup", "0::/other.scope\n"),
}
for name, (filename, text) in changes.items():
target = directory / filename
original = target.read_text()
with self.subTest(field=name):
with self.change_before_second_sample(lambda: target.write_text(text)):
with self.assertRaises(gate.Refusal):
self.proc.uid_threads(1000)
target.write_text(original)
def test_inode_and_device_drifts_refuse(self):
original = Path.stat
for field in ("st_ino", "st_dev"):
calls = 0
def stat(path, *args, **kwargs):
nonlocal calls
info = original(path, *args, **kwargs)
if path == self.root / "101":
calls += 1
if calls >= 3:
values = list(info)
values[1 if field == "st_ino" else 2] += 1
return os.stat_result(values)
return info
with self.subTest(field=field), patch.object(Path, "stat", stat):
with self.assertRaises(gate.Refusal):
self.proc.uid_threads(1000)
def test_disappearance_refuses(self):
target = self.root / "101" / "status"
with self.change_before_second_sample(target.unlink):
with self.assertRaisesRegex(gate.Refusal, "illisible/disparu"):
self.proc.uid_threads(1000)
def test_unreadable_task_directory_refuses(self):
original = Path.iterdir
def entries(path):
if path.name == "task":
raise PermissionError("fixture task unreadable")
return original(path)
with patch.object(Path, "iterdir", entries):
with self.assertRaisesRegex(gate.Refusal, "fixture task unreadable"):
self.proc.uid_threads(1000)
def test_threads_contradiction_refuses(self):
(self.root / "101" / "task" / "102").rmdir()
with self.assertRaisesRegex(gate.Refusal, "compte threads incohérent"):
self.proc.uid_threads(1000)
def test_equal_count_task_replacement_refuses(self):
def replace_task():
(self.root / "101" / "task" / "102").rename(
self.root / "101" / "task" / "103")
with self.change_before_second_sample(replace_task):
with self.assertRaisesRegex(gate.Refusal, "task drift"):
self.proc.uid_threads(1000)
def test_process_creation_and_end_refuse(self):
original = Path.iterdir
for creation in (True, False):
calls = 0
def entries(path):
nonlocal calls
if path == self.root:
calls += 1
if calls == 2:
if creation:
self.add_process(201)
else:
return iter(())
return original(path)
with self.subTest(creation=creation):
with patch.object(Path, "iterdir", entries):
with self.assertRaisesRegex(gate.Refusal, "process creation/end"):
self.proc.uid_threads(1000)
class AdmissionTests(unittest.TestCase):
def setUp(self):
self.sentinel = patch.object(
subprocess, "Popen", side_effect=native_spawn_forbidden)
self.sentinel.start()
self.addCleanup(self.sentinel.stop)
self.proc = FakeProc()
self.addCleanup(self.proc.close)
self.cgroups = FakeCgroups()
self.manager = FakeManager(self.cgroups)
self.barrier = FakeBarrier()
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.lab = Path(self.temporary.name)
self.manifest = {"scope": SPEC, "kind": "benign",
"qualification_mode": "nominal",
"argv": run.benign_command("nominal")}
self.reservations = []
self.journal = []
def preflight(self):
return gate.preflight(self.proc, self.cgroups, self.manager,
UID, PID, time.monotonic() + 1)
def admitted(self):
evidence = self.preflight()
self.cgroups.present = True
return gate.admit(SPEC, evidence, 43, self.proc, self.cgroups, self.manager,
time.monotonic() + 1)
def execute(self, collector=None, reserve=None, journal=None):
def collect(process, deadline):
process.returncode = 0
return [b"BENIGN_PAYLOAD_STARTED\n", b""]
return gate.run_scope(
self.lab, self.manifest, reserve or self.reservations.append,
journal or (lambda result: self.journal.append(copy.deepcopy(result))),
self.proc, self.cgroups, self.manager,
barrier_factory=lambda *args: self.barrier, collector=collector or collect)
def test_success_uses_production_orchestrator_and_single_release(self):
result, output = self.execute()
self.assertEqual(result["cause"], "exit")
self.assertEqual(self.barrier.released, 1)
self.assertEqual(len(self.reservations), 1)
self.assertTrue(self.barrier.closed)
self.assertEqual(self.manager.stopped, [SPEC["unit"]])
self.assertEqual([event["phase"] for event in result["phases"]],
["PRECHECK", "SCOPE_CREATED", "ADMITTED", "RELEASED",
"COLLECTED",
"PAYLOAD_STARTED", "CLEANED"])
self.assertEqual(self.proc.limits, (4096, 4096))
def test_session_scope_refused_before_reservation(self):
self.proc.identities[PID]["cgroup"] = (
f"/user.slice/user-{UID}.slice/session-6.scope")
result, _ = self.execute()
self.assertIn("session scope", result["error"])
self.assertEqual(self.reservations, [])
self.assertEqual(self.manager.created, 0)
def test_nproc_256_under_uid1126_refused_and_never_raised(self):
self.proc.limits = (256, 256)
result, _ = self.execute()
self.assertIn("RLIMIT_NPROC", result["error"])
self.assertEqual(self.proc.limits, (256, 256))
self.assertEqual(self.manager.created, 0)
def test_infinite_inherited_nproc_is_preserved(self):
self.proc.limits = (resource.RLIM_INFINITY, resource.RLIM_INFINITY)
self.assertEqual(self.preflight()["nproc"], self.proc.limits)
self.assertFalse(any(arg.startswith("--nproc")
for arg in run.command(self.lab)))
def test_manager_delegation_identity_and_app_refusals(self):
cases = (("owner", "Delegate", "no"), ("owner", "Id", "foreign"),
("owner", "InvocationID", ""), ("owner", "ControlGroup", "/"),
("app", "ActiveState", "inactive"), ("app", "ControlGroup", "/"))
for target, key, bad in cases:
with self.subTest(key=key, bad=bad):
original = getattr(self.manager, target)[key]
getattr(self.manager, target)[key] = bad
with self.assertRaises(gate.Refusal):
self.preflight()
getattr(self.manager, target)[key] = original
def test_controllers_and_writable_ancestor(self):
for controllers in (([], ["pids"], True), (["pids"], [], True),
(["pids"], ["pids"], False)):
self.cgroups.controllers = controllers
with self.assertRaises(gate.Refusal):
self.preflight()
def test_ancestor_headroom_and_inconsistent_current(self):
for maximum, current in (("1200", 1126), ("4096", -1), ("broken", 0)):
self.cgroups.ancestor.update(pids_max=maximum, pids_current=current)
with self.assertRaises(gate.Refusal):
self.preflight()
def test_scope_collision_no_spawn(self):
self.cgroups.present = True
result, _ = self.execute()
self.assertIn("occupé", result["error"])
self.assertEqual(self.manager.created, 0)
self.assertEqual(self.reservations, [])
def test_uid_divergence_before_release(self):
self.proc.identities[43]["uid"] = UID + 1
result, _ = self.execute()
self.assertEqual(self.barrier.released, 0)
self.assertEqual(result["cause"], "CLEANUP_UNPROVEN")
self.assertEqual(self.manager.stopped, [])
def test_kernel_budget_membership_and_shape_refusals(self):
for key, bad in (("pids_max", "max"), ("pids_max", "257"), ("pids_current", 0),
("pids_current", 2), ("procs", [43, 44]),
("children", ["foreign"]),
("uid", UID + 1)):
with self.subTest(key=key, bad=bad):
original = self.cgroups.record[key]
self.cgroups.record[key] = bad
with self.assertRaises(gate.Refusal):
self.admitted()
self.cgroups.record[key] = original
def test_scope_manager_properties_are_exact(self):
for key, bad in (("Id", "foreign.scope"), ("Description", "other"),
("InvocationID", ""), ("ControlGroup", "/wrong"),
("TasksMax", "infinity"), ("Delegate", "no"),
("RuntimeMaxUSec", "infinity"), ("KillMode", "process")):
with self.subTest(key=key):
original = self.manager.scope[key]
self.manager.scope[key] = bad
with self.assertRaises(gate.Refusal):
self.admitted()
self.manager.scope[key] = original
def test_startticks_change_during_admission(self):
original = self.proc.identity
calls = 0
def changing(pid):
nonlocal calls
value = original(pid)
if pid == 43:
calls += 1
if calls > 1:
value["startticks"] += 1
return value
self.proc.identity = changing
with self.assertRaisesRegex(gate.Refusal, "startticks"):
self.admitted()
def test_dead_pidfd_refused(self):
def dead(pid):
reader, writer = os.pipe()
os.close(writer)
return reader
self.proc.pidfd = dead
with self.assertRaisesRegex(gate.Refusal, "mort"):
self.admitted()
def test_identity_drift_never_stops_foreign_scope(self):
for key, bad in (("Id", "foreign.scope"), ("Description", "other"),
("InvocationID", "d" * 32), ("ControlGroup", "/foreign")):
with self.subTest(key=key):
self.manager.scope = {"Id": SPEC["unit"],
"Description": SPEC["description"],
"InvocationID": "c" * 32, "ControlGroup": GROUP,
**gate.POLICY}
receipt = self.admitted()
self.manager.scope[key] = bad
cleaned = gate.cleanup(SPEC, receipt, None, self.proc, self.cgroups,
self.manager, time.monotonic() + .2)
self.assertFalse(cleaned["proved"])
self.assertEqual(self.manager.stopped, [])
def test_inode_drift_and_recycled_pid_never_stop(self):
receipt = self.admitted()
self.cgroups.record["inode"] += 1
self.assertFalse(gate.cleanup(SPEC, receipt, None, self.proc, self.cgroups,
self.manager, time.monotonic() + .2)["proved"])
self.assertEqual(self.manager.stopped, [])
self.cgroups.record["inode"] -= 1
receipt = self.admitted()
self.proc.identities[43]["startticks"] += 1
self.assertFalse(gate.cleanup(SPEC, receipt, None, self.proc, self.cgroups,
self.manager, time.monotonic() + .2)["proved"])
self.assertEqual(self.manager.stopped, [])
def test_eof_and_timeout_before_release_never_exec(self):
for error in (EOFError("child EOF"), TimeoutError("barrier timeout")):
self.barrier.error = error
result, _ = self.execute()
self.assertNotEqual(result["cause"], "exit")
self.assertEqual(self.barrier.released, 0)
def test_failures_in_preflight_reservation_admission_and_drain(self):
for error in (OSError("read failure"), KeyboardInterrupt()):
with patch.object(self.proc, "uid_threads", side_effect=error):
result, _ = self.execute()
self.assertNotEqual(result["cause"], "exit")
self.assertEqual(self.manager.created, 0)
def fail(*args):
raise OSError("mock failure")
result, _ = self.execute(reserve=fail)
self.assertEqual(self.manager.created, 0)
self.assertNotEqual(result["cause"], "exit")
result, _ = self.execute(collector=fail)
self.assertNotEqual(result["cause"], "exit")
self.assertTrue(self.manager.process.stdout.closed)
self.assertTrue(self.manager.process.stderr.closed)
def test_spawn_failure_consumes_once_no_cleanup_exception(self):
self.manager.create_error = OSError("Popen failure")
result, _ = self.execute()
self.assertEqual(len(self.reservations), 1)
self.assertIn("Popen failure", result["error"])
self.assertEqual(self.manager.stopped, [])
def test_cleanup_stop_collection_and_events_failures_never_pass(self):
for mode in ("stop", "collection", "populated"):
with self.subTest(mode=mode):
self.manager = FakeManager(self.cgroups)
self.cgroups.present = False
self.cgroups.is_empty = mode != "populated"
if mode == "stop":
self.manager.stop_error = OSError("stop failed")
if mode == "collection":
self.manager.process.wait_error = subprocess.TimeoutExpired(
"mock", .1)
result, _ = self.execute()
self.assertEqual(result["cause"], "CLEANUP_UNPROVEN")
def test_journal_failure_does_not_abandon_child(self):
calls = 0
def journal(result):
nonlocal calls
calls += 1
if calls >= 4:
raise OSError("journal full")
result, _ = self.execute(journal=journal)
self.assertNotEqual(result["cause"], "exit")
self.assertTrue(self.manager.process.stdout.closed)
self.assertTrue(self.barrier.closed)
def test_no_egl_measurement_when_only_exec_requested(self):
self.manifest["kind"] = "egl"
result, _ = self.execute()
self.assertEqual(result["runtime"], "EGL_RESULT_UNINTERPRETED")
self.assertNotIn("PAYLOAD_STARTED",
[event["phase"] for event in result["phases"]])
def test_parent_death_is_separate_sealed_case(self):
self.manifest["qualification_mode"] = "parent-death"
with patch.object(gate.os, "_exit", side_effect=SystemExit(71)) as exit_process:
result, _ = self.execute()
exit_process.assert_called_once_with(71)
self.assertNotEqual(result["cause"], "exit")
self.assertIn("PARENT_DEATH_TEST",
[event["phase"] for event in result["phases"]])
def test_empty_observed_before_unit_disappears_allows_collection(self):
def collect(process, deadline):
self.cgroups.is_empty = True
self.cgroups.record.update(pids_current=0, procs=[])
time.sleep(.01) # Give the production watcher a real observation.
self.cgroups.present = False
process.returncode = 0
return [b"BENIGN_PAYLOAD_STARTED\n", b""]
self.cgroups.is_empty = False
result, _ = self.execute(collector=collect)
self.assertEqual(result["cause"], "exit")
self.assertTrue(result["cleanup"]["empty_observed"])
self.assertEqual(self.manager.stopped, [])
def test_disappeared_without_empty_proof_never_passes(self):
def collect(process, deadline):
self.cgroups.present = False
process.returncode = 0
return [b"BENIGN_PAYLOAD_STARTED\n", b""]
self.cgroups.is_empty = False
result, _ = self.execute(collector=collect)
self.assertEqual(result["cause"], "CLEANUP_UNPROVEN")
self.assertEqual(self.manager.stopped, [])
def test_drain_failure_keeps_captured_prefix_in_orchestrator(self):
def collect(process, deadline):
raise gate.DrainFailure("overflow", [b"prefix", b"diagnostic"])
result, channels = self.execute(collector=collect)
self.assertNotEqual(result["cause"], "exit")
self.assertEqual(channels, [b"prefix", b"diagnostic"])
def test_approved_main_uses_production_gate_and_durable_single_budget(self):
def execute(lab, manifest, reserve, journal):
def collect(process, deadline):
process.returncode = 0
return [b"BENIGN_PAYLOAD_STARTED\n", b""]
return gate.run_scope(
lab, manifest, reserve, journal, self.proc, self.cgroups, self.manager,
barrier_factory=lambda *args: self.barrier, collector=collect)
argv = ["run.py", "--lab", str(self.lab), "--manifest-sha256", "reviewed",
"--approve-benign-gate"]
with (
patch.object(sys, "argv", argv),
patch.object(run, "verify", return_value=self.manifest),
patch.object(run.gate, "run_scope", side_effect=execute),
contextlib.redirect_stdout(io.StringIO()),
contextlib.redirect_stderr(io.StringIO()),
):
self.assertEqual(run.main(), 0)
self.assertEqual(run.main(), 20)
self.assertEqual(self.manager.created, 1)
receipt = json.loads((self.lab / "result.json").read_text())
self.assertEqual(receipt["returncode"], 0)
self.assertTrue(receipt["cleanup"]["proved"])
self.assertTrue((self.lab / "ATTEMPT").exists())
def test_missing_benign_payload_marker_cannot_pass(self):
def collect(process, deadline):
process.returncode = 0
return [b"", b""]
result, _ = self.execute(collector=collect)
self.assertNotEqual(result["cause"], "exit")
self.assertIn("marqueur", result["error"])
class FilesystemTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.fs = gate.CgroupFS(self.root)
self.group = self.root / "scope"
self.group.mkdir()
contents = {"cgroup.type": "domain", "pids.max": "256", "pids.current": "1",
"cgroup.procs": "43", "cgroup.events": "populated 0\n",
"cgroup.threads": ""}
for name, value in contents.items():
(self.group / name).write_text(value)
def test_threaded_and_symlink_rejected_by_production_fs(self):
(self.group / "cgroup.type").write_text("threaded")
with self.assertRaisesRegex(gate.Refusal, "domain"):
self.fs.snapshot("/scope")
(self.root / "link").symlink_to(self.group)
with self.assertRaisesRegex(gate.Refusal, "symlink"):
self.fs.snapshot("/link")
def test_cgroup_v1_mount_and_nonroot_mount_rejected(self):
for text in (f"1 0 0:30 / {self.root} rw - cgroup cgroup rw\n",
f"1 0 0:30 /subtree {self.root} rw - cgroup2 cgroup rw\n"):
with patch.object(Path, "read_text", return_value=text):
with self.assertRaises(gate.Refusal):
self.fs.mount()
def test_recursive_populated_threads_and_symlink(self):
(self.group / "cgroup.procs").write_text("")
(self.group / "pids.current").write_text("0")
self.assertTrue(self.fs.empty("/scope"))
(self.group / "cgroup.threads").write_text("44")
self.assertFalse(self.fs.empty("/scope"))
(self.group / "cgroup.threads").write_text("")
(self.group / "foreign").symlink_to(self.root)
with self.assertRaises(gate.Refusal):
self.fs.empty("/scope")
def test_proc_v1_and_uid_drift_refused(self):
directory = self.root / "43"
directory.mkdir()
(directory / "stat").write_text("43 (name) " + " ".join(["1"] * 20))
(directory / "status").write_text(
f"Uid:\t{UID} {UID} {UID} {UID}\nThreads:\t1\n")
(directory / "cgroup").write_text("1:name:/legacy\n")
proc = gate.ProcFS(self.root)
with self.assertRaisesRegex(gate.Refusal, "v1"):
proc.identity(43)
(directory / "cgroup").write_text("0::/scope\n")
(directory / "status").write_text(
f"Uid:\t{UID} {UID + 1} {UID} {UID}\nThreads:\t1\n")
with self.assertRaisesRegex(gate.Refusal, "UID"):
proc.identity(43)
def test_proc_uid_thread_diagnostic_rejects_drift_and_disappearance(self):
directory = self.root / "43"
directory.mkdir()
(directory / "stat").write_text("43 (name) " + " ".join(["1"] * 20))
status = f"Uid:\t{UID} {UID} {UID} {UID}\nThreads:\t1\n"
(directory / "status").write_text(status)
(directory / "cgroup").write_text("0::/scope\n")
(directory / "task/43").mkdir(parents=True)
proc = gate.ProcFS(self.root)
self.assertEqual(proc.uid_threads(UID), 1)
(directory / "task/44").mkdir()
with self.assertRaisesRegex(gate.Refusal, "threads incohérent"):
proc.uid_threads(UID)
(directory / "task/44").rmdir()
original = Path.read_text
reads = 0
def changed(path, *args, **kwargs):
nonlocal reads
if path == directory / "status":
reads += 1
if reads > 1:
return status.replace("Threads:\t1", "Threads:\t2")
return original(path, *args, **kwargs)
with patch.object(Path, "read_text", changed):
with self.assertRaisesRegex(gate.Refusal, "threads incohérent"):
proc.uid_threads(UID)
(directory / "status").unlink()
with self.assertRaisesRegex(gate.Refusal, "illisible/disparu"):
proc.uid_threads(UID)
class BenignCommandTests(unittest.TestCase):
def test_parent_death_payload_sleeps_after_broken_pipe(self):
sleeps = []
def broken_write(_fd, _data):
raise BrokenPipeError("supervisor closed stdout")
fake_os = type("FakeOS", (), {"write": staticmethod(broken_write)})
fake_time = type("FakeTime", (), {
"sleep": staticmethod(sleeps.append),
})
script = run.benign_command("parent-death")[-1]
with patch.dict(sys.modules, {"os": fake_os, "time": fake_time}):
with self.assertRaises(BrokenPipeError):
exec(compile(script, "<benign-parent-death>", "exec"), {})
self.assertEqual(sleeps, [30])
self.assertIn("time.sleep(0.2)", run.benign_command("nominal")[-1])
class BarrierTests(unittest.TestCase):
"""Sockets locaux et threads inertes ; aucun fork/exec/systemd."""
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.path = Path(self.temp.name) / "gate.sock"
def peer(self, message, reply=None):
errors = []
def client():
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) as channel:
channel.settimeout(1)
channel.connect(str(self.path))
channel.sendall(message)
if reply is not None:
received = channel.recv(4096)
if received != b"RELEASE " + SPEC["nonce"].encode():
errors.append("wrong release")
channel.sendall(reply)
except BaseException as error:
errors.append(error)
thread = threading.Thread(target=client)
thread.start()
self.addCleanup(lambda: thread.join(timeout=2))
return thread, errors
def barrier(self, seconds=1):
barrier = gate.Barrier(self.path, SPEC["nonce"], UID, PID,
time.monotonic() + seconds)
self.addCleanup(barrier.close)
return barrier
def test_real_channel_peer_credentials_and_single_release(self):
barrier = self.barrier()
message = json.dumps({"nonce": SPEC["nonce"], "pid": PID}).encode()
thread, errors = self.peer(message, b"EXEC_REQUESTED " + SPEC["nonce"].encode())
self.assertEqual(barrier.accept(), PID)
barrier.release()
with self.assertRaisesRegex(gate.Refusal, "replay"):
barrier.accept()
with self.assertRaisesRegex(gate.Refusal, "seconde"):
barrier.release()
thread.join(timeout=2)
self.assertEqual(errors, [])
def test_wrong_nonce_and_pid_refuse(self):
messages = ({"nonce": "bad", "pid": PID},
{"nonce": SPEC["nonce"], "pid": PID + 1})
for data in messages:
barrier = self.barrier()
thread, _ = self.peer(json.dumps(data).encode())
with self.assertRaisesRegex(gate.Refusal, "nonce/PID"):
barrier.accept()
thread.join(timeout=2)
barrier.close()
# Remove registered cleanup because close is intentionally idempotent.
def test_eof_and_accept_timeout(self):
barrier = self.barrier(.05)
with self.assertRaises(TimeoutError):
barrier.accept()
barrier.close()
barrier = self.barrier()
thread, _ = self.peer(b"")
with self.assertRaises((ValueError, gate.Refusal)):
barrier.accept()
thread.join(timeout=2)
def test_bootstrap_no_exec_on_eof_wrong_nonce_parentdeath_timeout(self):
for action in ("eof", "wrong", "death", "timeout"):
with self.subTest(action=action):
listener = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
listener.bind(str(self.path))
listener.listen(1)
reader, writer = os.pipe()
class Proc:
def identity(self, pid):
return {"pid": PID, "uid": UID, "startticks": 123}
def pidfd(self, pid):
return reader
def server():
with listener.accept()[0] as channel:
channel.recv(4096)
if action == "wrong":
channel.sendall(b"RELEASE wrong")
elif action == "death":
os.close(writer)
# The pidfd EOF itself proves parent death to bootstrap.
elif action == "timeout":
time.sleep(.05)
thread = threading.Thread(target=server)
thread.start()
with patch.object(gate, "select", wraps=gate.select) as select_module:
if action == "timeout":
select_module.select.return_value = ([], [], [])
with self.assertRaises((gate.Refusal, OSError)):
gate.bootstrap(self.path, SPEC["nonce"], PID, 123,
["forbidden"], Proc(),
execute=native_spawn_forbidden)
thread.join(timeout=2)
listener.close()
self.path.unlink()
if action != "death":
os.close(writer)
def test_bootstrap_exec_boundary_is_clean_and_closes_control_fds(self):
listener = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET)
self.addCleanup(listener.close)
listener.bind(str(self.path))
listener.listen(1)
reader, writer = os.pipe()
self.addCleanup(lambda: os.close(writer))
observations = []
errors = []
class Proc:
def identity(self, pid):
return {"pid": PID, "uid": UID, "startticks": 123}
def pidfd(self, pid):
return reader
def server():
try:
with listener.accept()[0] as channel:
channel.recv(4096)
channel.sendall(b"RELEASE " + SPEC["nonce"].encode())
observations.append(channel.recv(4096))
except BaseException as error:
errors.append(error)
thread = threading.Thread(target=server)
thread.start()
self.addCleanup(lambda: thread.join(timeout=2))
executed = []
with patch.object(gate.os, "closerange") as closed, \
patch.object(gate.os, "chdir") as chdir:
with self.assertRaisesRegex(gate.Refusal, "exec retourné"):
gate.bootstrap(self.path, SPEC["nonce"], PID, 123, ["fixed", "payload"],
Proc(), execute=lambda *args: executed.append(args))
thread.join(timeout=2)
self.assertEqual(errors, [])
self.assertEqual(executed, [("fixed", ["fixed", "payload"], gate.CLEAN_ENV)])
self.assertEqual(observations, [b"EXEC_REQUESTED " + SPEC["nonce"].encode()])
self.assertEqual(closed.call_args.args[0], 3)
chdir.assert_called_once_with("/tmp")
with self.assertRaises(OSError):
os.fstat(reader)
class ManagerTests(unittest.TestCase):
def test_actual_argv_and_environment_with_injected_popen(self):
seen = []
manager = gate.SystemManager(
UID, popen=lambda argv, **kwargs: seen.append((argv, kwargs)))
manager.create(SPEC, ["fixed-bootstrap"])
argv, kwargs = seen[0]
self.assertIn("--scope", argv)
self.assertIn("--property=TasksMax=256", argv)
self.assertIn("--property=RuntimeMaxSec=12s", argv)
self.assertIn("--property=TimeoutStopSec=2s", argv)
self.assertIn("--property=KillMode=control-group", argv)
self.assertEqual(argv[-1], "fixed-bootstrap")
self.assertTrue(kwargs["close_fds"])
self.assertEqual(kwargs["env"]["XDG_RUNTIME_DIR"], f"/run/user/{UID}")
self.assertNotIn("pass_fds", kwargs)
self.assertNotIn("DBUS_SESSION_BUS_ADDRESS", gate.CLEAN_ENV)
class ProductionDrainTests(unittest.TestCase):
"""Pipes réels à taille minuscule, sans enfant ni exécutable."""
def pipes(self, stdout=b"", stderr=b""):
process = FakeProcess()
for name, data in (("stdout", stdout), ("stderr", stderr)):
reader, writer = os.pipe()
os.write(writer, data)
os.close(writer)
stream = os.fdopen(reader, "rb")
setattr(process, name, stream)
self.addCleanup(stream.close)
return process
def test_exact_bound_and_both_channels(self):
process = self.pipes(b"x" * 16, b"y" * 16)
channels = gate.drain(process, time.monotonic() + .1, limit=16)
self.assertEqual(channels, [b"x" * 16, b"y" * 16])
self.assertEqual(process.returncode, 0)
def test_overflow_preserves_bounded_prefix(self):
process = self.pipes(b"x" * 17)
with self.assertRaises(gate.DrainFailure) as context:
gate.drain(process, time.monotonic() + .1, limit=16)
self.assertIn("overflow", str(context.exception))
self.assertEqual(context.exception.channels[0], b"x" * 16)
def test_timeout_and_read_failure_are_bounded(self):
process = self.pipes(b"x")
with self.assertRaises(gate.DrainFailure):
gate.drain(process, time.monotonic() - .1)
with patch.object(gate.os, "read", side_effect=OSError("mock read error")):
with self.assertRaisesRegex(gate.DrainFailure, "mock read"):
gate.drain(process, time.monotonic() + .1)
def test_manager_call_failure_collects_only_its_child(self):
process = self.pipes(b"x" * 17)
manager = gate.SystemManager(UID, popen=lambda *args, **kwargs: process)
with patch.object(gate, "drain", side_effect=OSError("manager drain crash")):
with self.assertRaisesRegex(OSError, "manager drain"):
manager.call(["injected"], time.monotonic() + .2)
self.assertTrue(process.killed)
self.assertTrue(process.stdout.closed)
self.assertTrue(process.stderr.closed)
class ElfPreparationTests(unittest.TestCase):
"""Analyse ELF de production avec capture passive simulée."""
HEADER = ("Class: ELF64\nMachine: Advanced Micro Devices X86-64\n"
"Build ID: abc123\n")
def test_systemd_direct_runpath_is_recorded_and_not_inherited(self):
import prepare
tool = Path("/usr/bin/systemctl")
library = Path("/usr/lib/systemd/libsystemd-test.so")
libc = Path("/usr/lib/libc.so.6")
data = {
tool: self.HEADER + "(RUNPATH) [/usr/lib/systemd]\n"
"(NEEDED) [libsystemd-test.so]\n",
library: self.HEADER + "(NEEDED) [libc.so.6]\n",
libc: self.HEADER,
}
# A private namesake must not capture a transitive libc edge.
candidates = {library, libc, Path("/usr/lib/systemd/libc.so.6")}
with (
patch.object(prepare, "capture", side_effect=lambda argv: data[Path(argv[-1])].encode()),
patch.object(Path, "is_file", lambda path: path in candidates),
patch.object(prepare, "trusted_system_path") as trusted,
):
closure = prepare.elf_closure([tool])
self.assertEqual(closure[str(tool)]["runpath"], ["/usr/lib/systemd"])
self.assertEqual(closure[str(library)]["runpath"], [])
self.assertEqual(set(closure), {str(path) for path in data})
trusted.assert_any_call(Path("/usr/lib/systemd"))
def test_unapproved_runpaths_and_rpath_are_refused(self):
import prepare
for tag, value in (("RUNPATH", "$ORIGIN"), ("RUNPATH", "relative"),
("RUNPATH", "/tmp"),
("RUNPATH", "/usr/lib/systemd:/usr/lib"),
("RUNPATH", "/usr/lib/systemd:"),
("RPATH", "/usr/lib/systemd")):
with self.subTest(tag=tag, value=value), patch.object(
prepare, "capture", return_value=(
self.HEADER + f"({tag}) [{value}]\n").encode()):
with self.assertRaises(ValueError):
prepare.elf_closure([Path("/usr/bin/systemctl")])
with patch.object(prepare, "capture", return_value=(
self.HEADER + "(RUNPATH) [/usr/lib/systemd]\n").encode()):
with self.assertRaises(ValueError):
prepare.elf_closure([Path("/usr/bin/other")])
def test_writable_system_component_is_refused(self):
import prepare
with tempfile.TemporaryDirectory() as temporary:
with self.assertRaisesRegex(ValueError, "modifiable"):
prepare.trusted_system_path(Path(temporary))
class PreparationTests(unittest.TestCase):
def test_benign_preparation_uses_no_compiler_and_seals_actual_scripts(self):
import prepare
with tempfile.TemporaryDirectory() as temporary:
lab = Path(temporary) / "fresh"
argv = ["prepare.py", "--lab", str(lab), "--benign"]
with (
patch.object(sys, "argv", argv),
patch.object(prepare, "capture", side_effect=native_spawn_forbidden),
patch.object(prepare, "elf_closure", return_value={}) as closure,
contextlib.redirect_stdout(io.StringIO()),
):
prepare.main()
manifest = json.loads((lab / "manifest.json").read_text())
closure.assert_called_once()
self.assertEqual(manifest["kind"], "benign")
self.assertEqual(manifest["argv"], run.benign_command("nominal"))
self.assertEqual(manifest["scope"], gate.policy(manifest["scope"]["nonce"]))
self.assertIn(str(lab / "fixture/cgroup_gate.py"), manifest["files"])
self.assertFalse((lab / "ATTEMPT").exists())
self.assertFalse((lab / "bin/probe").exists())
def test_exact_isolated_passive_cli_creates_no_cache_or_process_payload(self):
import prepare
with tempfile.TemporaryDirectory() as temporary:
lab = Path(temporary) / "fresh"
preparation_argv = ["prepare.py", "--lab", str(lab), "--benign"]
with (
patch.object(sys, "argv", preparation_argv),
patch.object(prepare, "capture", side_effect=native_spawn_forbidden),
patch.object(prepare, "elf_closure", return_value={}),
contextlib.redirect_stdout(io.StringIO()),
):
prepare.main()
manifest_hash = run.digest(lab / "manifest.json")
argv = ["/usr/bin/python3", "-I", str(lab / "fixture/run.py"),
"--lab", str(lab), "--manifest-sha256", manifest_hash]
before = sorted(str(path.relative_to(lab)) for path in lab.rglob("*"))
def inert_only(command, *args, **kwargs):
if command != argv:
return native_spawn_forbidden(command, *args, **kwargs)
return INERT_POPEN(command, *args, **kwargs)
# This exact source-only Python command is permitted; every other
# executable/argument boundary remains forbidden by the sentinel.
with patch.object(subprocess, "Popen", side_effect=inert_only):
process = subprocess.run(argv, env=run.HOST_ENV, capture_output=True,
timeout=3, check=False)
self.assertEqual(process.returncode, 0, process.stderr.decode())
self.assertEqual(json.loads(process.stdout)["runtime"], "NOT_RUN")
self.assertEqual(before, sorted(str(path.relative_to(lab))
for path in lab.rglob("*")))
self.assertFalse((lab / "ATTEMPT").exists())
if __name__ == "__main__":
unittest.main()